diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 1b49d8971d1..1e58abe1cc4 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1052,6 +1052,9 @@ "--test-bar-width", "--widget-color", "--text-link-decoration", + "--positronActionBar-background", + "--positronActionBar-border", + "--positronActionBar-height", "--positron-data-grid-column-header-description-font-size", "--positron-data-grid-column-header-sort-index-font-size", "--positron-data-grid-column-header-sort-index-font-variant-numeric", diff --git a/src/vs/base/browser/ui/positronComponents/button/button.tsx b/src/vs/base/browser/ui/positronComponents/button/button.tsx index 8c96bc2f32d..f5f904e70ca 100644 --- a/src/vs/base/browser/ui/positronComponents/button/button.tsx +++ b/src/vs/base/browser/ui/positronComponents/button/button.tsx @@ -6,14 +6,13 @@ // CSS. import 'vs/css!./button'; - // React. import * as React from 'react'; -import { CSSProperties, forwardRef, KeyboardEvent, MouseEvent, PropsWithChildren, useImperativeHandle, useRef } from 'react'; // eslint-disable-line no-duplicate-imports +import { CSSProperties, forwardRef, KeyboardEvent, MouseEvent, PropsWithChildren, useImperativeHandle, useRef, useState } from 'react'; // eslint-disable-line no-duplicate-imports // Other dependencies. import { positronClassNames } from 'vs/base/common/positronUtilities'; -import { IHoverManager } from 'vs/platform/positronActionBar/browser/positronActionBarState'; +import { IHoverManager } from 'vs/platform/hover/browser/hoverManager'; /** * MouseTrigger enumeration. @@ -47,6 +46,8 @@ interface ButtonProps { readonly mouseTrigger?: MouseTrigger; readonly onBlur?: () => void; readonly onFocus?: () => void; + readonly onMouseEnter?: () => void; + readonly onMouseLeave?: () => void; readonly onPressed?: (e: KeyboardModifiers) => void; } @@ -62,11 +63,28 @@ export const Button = forwardRef buttonRef.current, []); + // State hooks. + const [mouseInside, setMouseInside] = useState(false); + + // Hover useEffect. + React.useEffect(() => { + // If the mouse is inside, show the hover. This has the effect of showing the hover when + // mouseInside is set to true and updating the hover when the tooltip changes. + if (mouseInside) { + props.hoverManager?.showHover(buttonRef.current, props.tooltip); + } + }, [mouseInside, props.hoverManager, props.tooltip]); + + /** + * Sends the onPressed event. + * @param e The event that triggered the onPressed event. + */ const sendOnPressed = (e: MouseEvent | KeyboardEvent) => { // Consume the event. e.preventDefault(); e.stopPropagation(); + // Hide the hover. props.hoverManager?.hideHover(); // Raise the onPressed event if the button isn't disabled. @@ -106,37 +124,11 @@ export const Button = forwardRef that describes a user interaction with the mouse. */ const mouseEnterHandler = (e: MouseEvent) => { - // If there's a hover manager, see if there's a tooltip. - if (props.hoverManager) { - // Get the tooltip. - const tooltip = (() => { - if (!props.tooltip) { - // There isn't a tooltip. - return undefined; - } else if (typeof props.tooltip === 'string') { - // Return the string tooltip. - return props.tooltip; - } else { - // Return the dynamic tooltip. - return props.tooltip(); - } - })(); - - // If there's a tooltip, show it. - if (tooltip) { - props.hoverManager.showHover({ - content: tooltip, - target: buttonRef.current, - persistence: { - hideOnKeyDown: true, - hideOnHover: false - }, - appearance: { - showPointer: true - } - }, false); - } - } + // Set the mouse inside state. + setMouseInside(true); + + // Call the onMouseEnter callback. + props.onMouseEnter?.(); }; /** @@ -144,10 +136,14 @@ export const Button = forwardRef that describes a user interaction with the mouse. */ const mouseLeaveHandler = (e: MouseEvent) => { - // If there's a hover manager, hide hover. - if (props.hoverManager) { - props.hoverManager.hideHover(); - } + // Clear the mouse inside state. + setMouseInside(false); + + // Hide the hover. + props.hoverManager?.hideHover(); + + // Call the onMouseLeave callback. + props.onMouseLeave?.(); }; /** diff --git a/src/vs/loader.js b/src/vs/loader.js index 7f680ed4fd5..335de712907 100644 --- a/src/vs/loader.js +++ b/src/vs/loader.js @@ -1627,7 +1627,7 @@ var AMDLoader; } else { // Unit tests load client.js from 'http://localhost'. For example: // http://localhost:51262/e9416c1769b269baf1f33978a0695be1/node_modules/react-dom/umd/react-dom.production.min.js/client.js - const reactDomClientLocalhost = /(http:\/\/localhost:[0-9]+\/[0-9A-Fa-f]+)(\/node_modules\/react-dom\/umd\/react-dom.production.min.js\/client.js)/; + const reactDomClientLocalhost = /^(http:\/\/localhost:[0-9]+\/[0-9A-Fa-f]+)(\/node_modules\/react-dom\/umd\/react-dom.production.min.js\/client.js)$/; const result = paths[0].match(reactDomClientLocalhost); if (result && result.length === 3) { paths[0] = `${result[1]}/out/react-dom/client.js`; diff --git a/src/vs/platform/hover/browser/hoverManager.ts b/src/vs/platform/hover/browser/hoverManager.ts new file mode 100644 index 00000000000..09974075fd6 --- /dev/null +++ b/src/vs/platform/hover/browser/hoverManager.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * IHoverManager interface. + */ +export interface IHoverManager { + /** + * Shows a hover. + * @param target The target. + * @param content The content. + */ + showHover(target: HTMLElement, content?: string | (() => string | undefined)): void; + + /** + * Hides the hover if it was visible. + */ + hideHover(): void; +} diff --git a/src/vs/platform/positronActionBar/browser/components/actionBarActionButton.css b/src/vs/platform/positronActionBar/browser/components/actionBarActionButton.css new file mode 100644 index 00000000000..d736bf982e3 --- /dev/null +++ b/src/vs/platform/positronActionBar/browser/components/actionBarActionButton.css @@ -0,0 +1,4 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ diff --git a/src/vs/platform/positronActionBar/browser/components/actionBarActionButton.tsx b/src/vs/platform/positronActionBar/browser/components/actionBarActionButton.tsx new file mode 100644 index 00000000000..6927da59d7f --- /dev/null +++ b/src/vs/platform/positronActionBar/browser/components/actionBarActionButton.tsx @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +// CSS. +import 'vs/css!./actionBarActionButton'; + +// React. +import * as React from 'react'; +import { useEffect, useRef, useState } from 'react'; // eslint-disable-line no-duplicate-imports + +// Other dependencies. +import { IAction } from 'vs/base/common/actions'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { useStateRef } from 'vs/base/browser/ui/react/useStateRef'; +import { MenuItemAction } from 'vs/platform/actions/common/actions'; +import { IModifierKeyStatus, ModifierKeyEmitter } from 'vs/base/browser/dom'; +import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; +import { useRegisterWithActionBar } from 'vs/platform/positronActionBar/browser/useRegisterWithActionBar'; +import { usePositronActionBarContext } from 'vs/platform/positronActionBar/browser/positronActionBarContext'; +import { ActionBarButton, ActionBarButtonProps } from 'vs/platform/positronActionBar/browser/components/actionBarButton'; +import { actionTooltip, toMenuActionItem } from 'vs/platform/positronActionBar/common/helpers'; + +/** + * Constants. + */ +const CODICON_ID = /^codicon codicon-(.+)$/; + +/** + * Determines whether the alternative action should be used. + * @param accessibilityService The accessibility service. + * @param menuItemAction The menu item action. + * @param mouseOver Whether the mouse is over the action bar action button. + * @param modifierKeyStatus The modifier key status. + * @returns A value which indicates whether the alternative action should be used. + */ +const shouldUseAlternativeAction = ( + accessibilityService: IAccessibilityService, + menuItemAction?: MenuItemAction, + mouseOver?: boolean, + modifierKeyStatus?: IModifierKeyStatus +) => { + // If a menu item action was not supplied, return false + if (!menuItemAction) { + return false; + } + + // If there isn't an alt action, or there is and it's not enabled, return false + if (!menuItemAction.alt?.enabled) { + return false; + } + + // If the modifier key status was not supplied, get it from the modifier key emitter. + if (!modifierKeyStatus) { + modifierKeyStatus = ModifierKeyEmitter.getInstance().keyStatus; + } + + // If motion is not reduced and the alt key is pressed, return true. + if (!accessibilityService.isMotionReduced() && modifierKeyStatus.altKey) { + return true; + } + + // If the mouse is over the action bar action button and the shift or alt key is pressed, return + // true. + if (mouseOver && (modifierKeyStatus.shiftKey || modifierKeyStatus.altKey)) { + return true; + } + + // Do not use the alternative action. + return false; +}; + +/** + * ActionBarActionButtonProps interface. + */ +interface ActionBarActionButtonProps { + readonly action: IAction; +} + +/** + * ActionBarCommandButton component. + * @param props An ActionBarCommandButtonProps that contains the component properties. + * @returns The rendered component. + */ +export const ActionBarActionButton = (props: ActionBarActionButtonProps) => { + // Context hooks. + const context = usePositronActionBarContext(); + + // Reference hooks. + const buttonRef = useRef(undefined!); + + // Menu action item. + const menuActionItem = toMenuActionItem(props.action); + + // State hooks. + const [, setMouseInside, mouseInsideRef] = useStateRef(false); + const [useAlternativeAction, setUseAlternativeAction] = useState( + shouldUseAlternativeAction(context.accessibilityService, menuActionItem) + ); + + // Main use effect. + useEffect(() => { + // Create the disposable store for cleanup. + const disposableStore = new DisposableStore(); + + // Get the modifier key emitter and add the event listener to it. + const modifierKeyEmitter = ModifierKeyEmitter.getInstance(); + disposableStore.add(modifierKeyEmitter.event(modifierKeyStatus => { + setUseAlternativeAction(shouldUseAlternativeAction( + context.accessibilityService, + menuActionItem, + mouseInsideRef.current, + modifierKeyStatus + )); + })); + + // Return the cleanup function that will dispose of the disposables. + return () => disposableStore.dispose(); + }, [context.accessibilityService, menuActionItem, mouseInsideRef]); + + // Participate in roving tabindex. + useRegisterWithActionBar([buttonRef]); + + // Get the action we're going to render. + const action = menuActionItem && + useAlternativeAction && + menuActionItem.alt?.enabled ? menuActionItem.alt : props.action; + + // Build the dynamic properties. + const dynamicProps = ((): ActionBarButtonProps => { + // Extract the icon ID from the action's class. + const iconIdResult = action.class?.match(CODICON_ID); + const iconId = iconIdResult?.length === 2 ? iconIdResult[1] : undefined; + + // Return the properties. + return { + ariaLabel: action.label ?? action.tooltip, + iconId: iconId, + tooltip: actionTooltip( + context.contextKeyService, + context.keybindingService, + action, + !useAlternativeAction + ), + disabled: !action.enabled, + onMouseEnter: () => setMouseInside(true), + onMouseLeave: () => setMouseInside(false), + onPressed: () => + action.run() + }; + })(); + + // Render. + return ( + + ); +}; diff --git a/src/vs/platform/positronActionBar/browser/components/actionBarButton.css b/src/vs/platform/positronActionBar/browser/components/actionBarButton.css index 0e3a1e9ba7d..44a8c5554a3 100644 --- a/src/vs/platform/positronActionBar/browser/components/actionBarButton.css +++ b/src/vs/platform/positronActionBar/browser/components/actionBarButton.css @@ -1,5 +1,5 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2022 Posit Software, PBC. All rights reserved. + * Copyright (C) 2022-2024 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ @@ -14,8 +14,8 @@ } .action-bar-button { - height: 28px; padding: 0; + height: 28px; border: none; display: flex; cursor: pointer; @@ -31,10 +31,6 @@ background: var(--positronActionBar-hoverBackground); } -.action-bar-button.border { - border: 1px solid var(--vscode-positronTopActionBar-selectBorder); -} - .action-bar-button.fade-in { opacity: 0; animation: positronActionBarButton-fadeIn 0.25s ease-in 0.25s 1 forwards; @@ -49,6 +45,10 @@ outline: 1px solid var(--vscode-focusBorder) !important; } +.action-bar-button-action-button { + padding: 0; +} + .action-bar-button-face { display: flex; align-items: center; @@ -64,10 +64,10 @@ } .action-bar-button-icon { + padding: 0 6px; align-items: center; justify-content: center; display: flex !important; - width: var(--positronActionBar-iconSize); height: var(--positronActionBar-iconSize); } @@ -79,12 +79,40 @@ color: var(--positronActionBar-disabledForeground); } +.action-bar-button-icon.enabled-split { + display: flex; + padding: 0 2px 0 6px; +} + +.action-bar-button-icon.enabled-split:hover { + border-radius: 6px; + padding: 0 2px 0 6px; + filter: brightness(90%); + background: var(--positronActionBar-hoverBackground); +} + +.action-bar-button-drop-down-container { + display: flex; + padding-right: 5px; +} + +.action-bar-button-drop-down-button { + display: flex; + padding: 0 5px; +} + +.action-bar-button-drop-down-button:hover { + border-radius: 6px; + filter: brightness(90%); + background: var(--positronActionBar-hoverBackground); +} + .action-bar-button-drop-down-arrow { - width: 10px; align-items: center; justify-content: left; - font-size: 18px !important; + justify-content: center; display: flex !important; + font-size: 18px !important; height: var(--positronActionBar-iconSize); } diff --git a/src/vs/platform/positronActionBar/browser/components/actionBarButton.tsx b/src/vs/platform/positronActionBar/browser/components/actionBarButton.tsx index bff5ef5a586..001180bc286 100644 --- a/src/vs/platform/positronActionBar/browser/components/actionBarButton.tsx +++ b/src/vs/platform/positronActionBar/browser/components/actionBarButton.tsx @@ -8,7 +8,7 @@ import 'vs/css!./actionBarButton'; // React. import * as React from 'react'; -import { forwardRef, PropsWithChildren } from 'react'; // eslint-disable-line no-duplicate-imports +import { forwardRef, PropsWithChildren, useImperativeHandle, useRef } from 'react'; // eslint-disable-line no-duplicate-imports // Other dependencies. import { Button } from 'vs/base/browser/ui/positronComponents/button/button'; @@ -19,19 +19,22 @@ import { usePositronActionBarContext } from 'vs/platform/positronActionBar/brows * ActionBarButtonProps interface. */ export interface ActionBarButtonProps { - fadeIn?: boolean; - iconId?: string; - iconFontSize?: number; - text?: string; - maxTextWidth?: number; - border?: boolean; - dropDown?: boolean; - align?: 'left' | 'right'; - layout?: 'loose' | 'tight'; - tooltip?: string | (() => string | undefined); - disabled?: boolean; - ariaLabel?: string; - onPressed?: () => void; + readonly fadeIn?: boolean; + readonly iconId?: string; + readonly iconFontSize?: number; + readonly text?: string; + readonly maxTextWidth?: number; + readonly align?: 'left' | 'right'; + readonly tooltip?: string | (() => string | undefined); + readonly dropdownTooltip?: string | (() => string | undefined); + readonly disabled?: boolean; + readonly ariaLabel?: string; + readonly dropdownAriaLabel?: string; + readonly dropdownIndicator?: 'disabled' | 'enabled' | 'enabled-split'; + readonly onMouseEnter?: () => void; + readonly onMouseLeave?: () => void; + readonly onPressed?: () => void; + readonly onDropdownPressed?: () => void; } /** @@ -47,6 +50,15 @@ export const ActionBarButton = forwardRef< // Context hooks. const context = usePositronActionBarContext(); + // Reference hooks. + const buttonRef = useRef(undefined!); + const dropdownButtonRef = useRef(undefined!); + + // Imperative handle to ref. + useImperativeHandle(ref, () => props.dropdownIndicator === 'enabled-split' ? + dropdownButtonRef.current : buttonRef.current + ); + // Create the icon style. let iconStyle: React.CSSProperties = {}; if (props.iconId && props.iconFontSize) { @@ -58,29 +70,21 @@ export const ActionBarButton = forwardRef< // https://github.com/microsoft/vscode/issues/181739#issuecomment-1779701917 const ariaLabel = props.ariaLabel ? props.ariaLabel : props.text; - // Render. - return ( - + ); + } else { + return ( +
+ + {props.children}
- - ); + ); + } }); // Set the display name. diff --git a/src/vs/platform/positronActionBar/browser/components/actionBarCommandButton.tsx b/src/vs/platform/positronActionBar/browser/components/actionBarCommandButton.tsx index 269b04c7d21..c795cec281c 100644 --- a/src/vs/platform/positronActionBar/browser/components/actionBarCommandButton.tsx +++ b/src/vs/platform/positronActionBar/browser/components/actionBarCommandButton.tsx @@ -1,22 +1,27 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2022 Posit Software, PBC. All rights reserved. + * Copyright (C) 2022-2024 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ +// CSS. import 'vs/css!./actionBarCommandButton'; + +// React. import * as React from 'react'; import { useEffect, useRef, useState } from 'react'; // eslint-disable-line no-duplicate-imports + +// Other dependencies. import { DisposableStore } from 'vs/base/common/lifecycle'; import { CommandCenter } from 'vs/platform/commandCenter/common/commandCenter'; +import { useRegisterWithActionBar } from 'vs/platform/positronActionBar/browser/useRegisterWithActionBar'; import { usePositronActionBarContext } from 'vs/platform/positronActionBar/browser/positronActionBarContext'; import { ActionBarButton, ActionBarButtonProps } from 'vs/platform/positronActionBar/browser/components/actionBarButton'; -import { useRegisterWithActionBar } from 'vs/platform/positronActionBar/browser/useRegisterWithActionBar'; /** * ActionBarCommandButtonProps interface. */ interface ActionBarCommandButtonProps extends ActionBarButtonProps { - commandId: string; + readonly commandId: string; } /** @@ -27,7 +32,9 @@ interface ActionBarCommandButtonProps extends ActionBarButtonProps { export const ActionBarCommandButton = (props: ActionBarCommandButtonProps) => { // Hooks. const positronActionBarContext = usePositronActionBarContext(); - const [disabled, setDisabled] = useState(!positronActionBarContext.isCommandEnabled(props.commandId)); + const [commandDisabled, setCommandDisabled] = useState( + !positronActionBarContext.isCommandEnabled(props.commandId) + ); const buttonRef = useRef(undefined!); // Add our event handlers. @@ -45,21 +52,18 @@ export const ActionBarCommandButton = (props: ActionBarCommandButtonProps) => { disposableStore.add(positronActionBarContext.contextKeyService.onDidChangeContext(e => { // If any of the precondition keys are affected, update the enabled state. if (e.affectsSome(keys)) { - setDisabled(!positronActionBarContext.contextKeyService.contextMatchesRules(commandInfo.precondition)); + setCommandDisabled(!positronActionBarContext.contextKeyService.contextMatchesRules(commandInfo.precondition)); } })); } // Return the clean up for our event handlers. return () => disposableStore.dispose(); - }, []); + }, [positronActionBarContext.contextKeyService, props.commandId]); // Participate in roving tabindex. useRegisterWithActionBar([buttonRef]); - // Handlers. - const executeHandler = () => positronActionBarContext.commandService.executeCommand(props.commandId); - // Returns a dynamic tooltip for the command button. const tooltip = (): string | undefined => { // Get the title for the command from the command center. @@ -81,5 +85,15 @@ export const ActionBarCommandButton = (props: ActionBarCommandButtonProps) => { }; // Render. - return ; + return ( + + positronActionBarContext.commandService.executeCommand(props.commandId) + } + /> + ); }; diff --git a/src/vs/platform/positronActionBar/browser/components/actionBarFind.tsx b/src/vs/platform/positronActionBar/browser/components/actionBarFind.tsx index 14517d69463..ffdc59ee417 100644 --- a/src/vs/platform/positronActionBar/browser/components/actionBarFind.tsx +++ b/src/vs/platform/positronActionBar/browser/components/actionBarFind.tsx @@ -1,5 +1,5 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2022 Posit Software, PBC. All rights reserved. + * Copyright (C) 2022-2024 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ @@ -65,8 +65,8 @@ export const ActionBarFind = (props: ActionBarFindProps) => { )} - localize('positronFindPrevious', "Find previous"))()} disabled={!props.findResults} onPressed={() => props.onFindPrevious!()} /> - localize('positronFindNext', "Find next"))()} disabled={!props.findResults} onPressed={() => props.onFindNext!()} /> + localize('positronFindPrevious', "Find previous"))()} disabled={!props.findResults} onPressed={() => props.onFindPrevious!()} /> + localize('positronFindNext', "Find next"))()} disabled={!props.findResults} onPressed={() => props.onFindNext!()} /> ); }; diff --git a/src/vs/platform/positronActionBar/browser/components/actionBarMenuButton.tsx b/src/vs/platform/positronActionBar/browser/components/actionBarMenuButton.tsx index 8341cd2ef3b..eafae0673c2 100644 --- a/src/vs/platform/positronActionBar/browser/components/actionBarMenuButton.tsx +++ b/src/vs/platform/positronActionBar/browser/components/actionBarMenuButton.tsx @@ -1,30 +1,38 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2022 Posit Software, PBC. All rights reserved. + * Copyright (C) 2022-2024 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ +// CSS. import 'vs/css!./actionBarMenuButton'; + +// React. import * as React from 'react'; import { useEffect, useRef } from 'react'; // eslint-disable-line no-duplicate-imports + +// Other dependencies. import { IAction } from 'vs/base/common/actions'; -import { AnchorAlignment, AnchorAxisAlignment } from 'vs/base/browser/ui/contextview/contextview'; import { IContextMenuEvent } from 'vs/base/browser/contextmenu'; +import { AnchorAlignment, AnchorAxisAlignment } from 'vs/base/browser/ui/contextview/contextview'; import { ActionBarButton } from 'vs/platform/positronActionBar/browser/components/actionBarButton'; -import { usePositronActionBarContext } from 'vs/platform/positronActionBar/browser/positronActionBarContext'; import { useRegisterWithActionBar } from 'vs/platform/positronActionBar/browser/useRegisterWithActionBar'; +import { usePositronActionBarContext } from 'vs/platform/positronActionBar/browser/positronActionBarContext'; /** * ActionBarMenuButtonProps interface. */ interface ActionBarMenuButtonProps { - iconId?: string; - iconFontSize?: number; - text?: string; - ariaLabel?: string; - maxTextWidth?: number; - align?: 'left' | 'right'; - tooltip?: string | (() => string | undefined); - actions: () => readonly IAction[] | Promise; + readonly iconId?: string; + readonly iconFontSize?: number; + readonly text?: string; + readonly ariaLabel?: string; + readonly dropdownAriaLabel?: string; + readonly maxTextWidth?: number; + readonly align?: 'left' | 'right'; + readonly tooltip?: string | (() => string | undefined); + readonly dropdownTooltip?: string | (() => string | undefined); + readonly dropdownIndicator?: 'disabled' | 'enabled' | 'enabled-split'; + readonly actions: () => readonly IAction[] | Promise; } /** @@ -33,8 +41,10 @@ interface ActionBarMenuButtonProps { * @returns The rendered component. */ export const ActionBarMenuButton = (props: ActionBarMenuButtonProps) => { - // Hooks. + // Context hooks. const positronActionBarContext = usePositronActionBarContext(); + + // Reference hooks. const buttonRef = useRef(undefined!); // Manage the aria-haspopup and aria-expanded attributes. @@ -42,26 +52,30 @@ export const ActionBarMenuButton = (props: ActionBarMenuButtonProps) => { buttonRef.current.setAttribute('aria-haspopup', 'menu'); }, []); + // Manage the aria-expanded attribute. useEffect(() => { if (positronActionBarContext.menuShowing) { buttonRef.current.setAttribute('aria-expanded', 'true'); } else { buttonRef.current.removeAttribute('aria-expanded'); } - }, [positronActionBarContext.menuShowing]); // Participate in roving tabindex. useRegisterWithActionBar([buttonRef]); - // Handlers. - const pressedHandler = async () => { - // Get the actions. + /** + * Shows the menu. + * @returns A Promise that resolves when the menu is shown. + */ + const showMenu = async () => { + // Get the actions. If there are no actions, return. const actions = await props.actions(); if (!actions.length) { return; } + // Set the menu showing state and show the context menu. positronActionBarContext.setMenuShowing(true); positronActionBarContext.contextMenuService.showContextMenu({ getActions: () => actions, @@ -89,5 +103,23 @@ export const ActionBarMenuButton = (props: ActionBarMenuButtonProps) => { }; // Render. - return ; + return ( + { + if (props.dropdownIndicator !== 'enabled-split') { + await showMenu(); + } else { + // Get the actions and run the first action. + const actions = await props.actions(); + if (actions.length) { + actions[0].run(); + } + } + }} + onDropdownPressed={async () => await showMenu()} + /> + ); }; diff --git a/src/vs/platform/positronActionBar/browser/positronActionBar.css b/src/vs/platform/positronActionBar/browser/positronActionBar.css index 0332f50d289..87c7cc42ae0 100644 --- a/src/vs/platform/positronActionBar/browser/positronActionBar.css +++ b/src/vs/platform/positronActionBar/browser/positronActionBar.css @@ -1,5 +1,5 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2022 Posit Software, PBC. All rights reserved. + * Copyright (C) 2022-2024 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ @@ -21,7 +21,7 @@ .positron-action-bar.small { --positronActionBar-height: 32px; - --positronActionBar-iconSize: 26px; + --positronActionBar-iconSize: 28px; --positronActionBar-tooltipTop: 30px; } diff --git a/src/vs/platform/positronActionBar/browser/positronActionBarHoverManager.ts b/src/vs/platform/positronActionBar/browser/positronActionBarHoverManager.ts new file mode 100644 index 00000000000..daa60403784 --- /dev/null +++ b/src/vs/platform/positronActionBar/browser/positronActionBarHoverManager.ts @@ -0,0 +1,185 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IHoverWidget } from 'vs/base/browser/ui/hover/hover'; +import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; +import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; +import { IHoverManager } from 'vs/platform/hover/browser/hoverManager'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; + +/** + * Constants. + */ +const INSTANT_HOVER_TIME_LIMIT = 200; + +/** + * PositronActionBarHoverManager class. + */ +export class PositronActionBarHoverManager extends Disposable implements IHoverManager { + //#region Private Properties + + /** + * The hover delay. + */ + private _hoverDelay: number; + + /** + * Gets or sets the hover leave time. + */ + private _hoverLeaveTime: number = 0; + + /** + * Gets or sets the timeout. + */ + private _timeout?: NodeJS.Timeout; + + /** + * Gets or sets the last hover widget. + */ + private _lastHoverWidget?: IHoverWidget; + + /** + * Gets a value which indicates whether the hover is instantly hovering. + * @returns A value which indicates whether the hover is instantly hovering. + */ + private get isInstantlyHovering(): boolean { + return Date.now() - this._hoverLeaveTime < INSTANT_HOVER_TIME_LIMIT; + } + + //#endregion Private Properties + + //#region Constructor & Dispose + + /** + * Constructor. + * @param _compact A value which indicates whether the hover is compact. + * @param _configurationService The configuration service. + * @param _hoverService The hover service. + */ + constructor( + private readonly _compact: boolean, + private readonly _configurationService: IConfigurationService, + private readonly _hoverService: IHoverService + ) { + // Call the base class's method. + super(); + + // Initialize and track changes to the hover delay configuration. + this._hoverDelay = this._configurationService.getValue('workbench.hover.delay'); + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('workbench.hover.delay')) { + this._hoverDelay = this._configurationService.getValue('workbench.hover.delay'); + } + })); + + // Hide the hover when the hover manager is disposed. + this._register(toDisposable(() => this._hoverService.hideHover())); + } + + /** + * Disposes the hover manager. + */ + override dispose(): void { + // Clear pending timeout. + if (this._timeout) { + clearTimeout(this._timeout); + this._timeout = undefined; + } + + // If there is a last hover widget, dispose of it. + if (this._lastHoverWidget) { + this._lastHoverWidget.dispose(); + this._lastHoverWidget = undefined; + } + + // Call the base class's dispose method. + super.dispose(); + } + + //#endregion Constructor & Dispose + + //#region Public Methods + + /** + * Shows a hover. + * @param target The target. + * @param content The content. + */ + public showHover(target: HTMLElement, content?: string | (() => string | undefined)): void { + // Hide the hover. + this.hideHover(); + + // If there is no content, return. + if (!content) { + return; + } + + /** + * Shows the hover. + * @param content The content. + * @param skipFadeInAnimation A value which indicates whether to skip fade in animation. + */ + const showHover = (content: string, skipFadeInAnimation: boolean) => { + // Show the hover and set the last hover widget. + this._lastHoverWidget = this._hoverService.showHover({ + content, + target, + position: { + hoverPosition: HoverPosition.BELOW + }, + persistence: { + hideOnKeyDown: true, + hideOnHover: false + }, + appearance: { + compact: this._compact, + showPointer: true, + skipFadeInAnimation + } + }, false); + }; + + // Get the content. + if (typeof content !== 'string') { + content = content(); + if (!content) { + return; + } + } + + // If a hover was recently shown, show the hover immediately and skip the fade in animation. + // If not, schedule the hover for display with fade in animation. + if (this.isInstantlyHovering) { + showHover(content, true); + } else { + // Set the timeout to show the hover. + this._timeout = setTimeout(() => + showHover(content, false), + this._hoverDelay + ); + } + } + + /** + * Hides the hover. + */ + public hideHover(): void { + // Clear pending timeout. + if (this._timeout) { + clearTimeout(this._timeout); + this._timeout = undefined; + } + + // If there is a last hover widget, dispose of it and set the hover leave time. + if (this._lastHoverWidget) { + this._lastHoverWidget.dispose(); + this._lastHoverWidget = undefined; + this._hoverLeaveTime = Date.now(); + } + } + + //#endregion Public Methods +} diff --git a/src/vs/platform/positronActionBar/browser/positronActionBarState.tsx b/src/vs/platform/positronActionBar/browser/positronActionBarState.tsx index fdaae523ae1..4077024a139 100644 --- a/src/vs/platform/positronActionBar/browser/positronActionBarState.tsx +++ b/src/vs/platform/positronActionBar/browser/positronActionBarState.tsx @@ -8,139 +8,25 @@ import { useEffect, useState } from 'react'; // Other dependencies. import { unmnemonicLabel } from 'vs/base/common/labels'; +import { DisposableStore } from 'vs/base/common/lifecycle'; import { IHoverService } from 'vs/platform/hover/browser/hover'; import { Action, IAction, Separator } from 'vs/base/common/actions'; -import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; import { ICommandService } from 'vs/platform/commands/common/commands'; -import { IHoverOptions, IHoverWidget } from 'vs/base/browser/ui/hover/hover'; +import { IHoverManager } from 'vs/platform/hover/browser/hoverManager'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { CommandCenter } from 'vs/platform/commandCenter/common/commandCenter'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; -import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; import { ContextKeyExpression, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; - -/** - * IHoverManager interface. - */ -export interface IHoverManager { - /** - * Shows a hover. - * @param options A IHoverOptions that contains the hover options. - * @param focus A value which indicates whether to focus the hover when it is shown. - */ - showHover(options: IHoverOptions, focus?: boolean): void; - - /** - * Hides a hover. - */ - hideHover(): void; -} - -/** - * HoverManager class. - */ -class HoverManager extends Disposable { - /** - * Gets or sets the hover leave time. - */ - private static _hoverLeaveTime: number = 0; - - /** - * The hover delay. - */ - private _hoverDelay: number; - - /** - * Gets or sets the timeout. - */ - private _timeout?: NodeJS.Timeout; - - /** - * Gets or sets the last hover widget. - */ - private _lastHoverWidget?: IHoverWidget; - - /** - * Constructor. - * @param configurationService The configuration service. - * @param _hoverService The hover service. - */ - constructor( - configurationService: IConfigurationService, - private readonly _hoverService: IHoverService - ) { - // Call the base class's method. - super(); - - // Initialize and track changes to the hover delay. - this._hoverDelay = configurationService.getValue('workbench.hover.delay'); - this._register(configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('workbench.hover.delay')) { - this._hoverDelay = configurationService.getValue('workbench.hover.delay'); - } - })); - - // Hide the hover when the hover manager is disposed. - this._register(toDisposable(() => this.hideHover())); - } - - /** - * Shows a hover. - * @param options A IHoverOptions that contains the hover options. - * @param focus A value which indicates whether to focus the hover when it is shown. - */ - public showHover(options: IHoverOptions, focus?: boolean) { - // Hide the hover. - this.hideHover(); - - /** - * Shows the hover. - * @param skipFadeInAnimation A value which indicates whether to skip fade in animation. - */ - const showHover = (skipFadeInAnimation: boolean) => { - // Update the position and appearance options. - options.position = { ...options.position, hoverPosition: HoverPosition.BELOW }; - options.appearance = { ...options.appearance, skipFadeInAnimation }; - - // Show the hover and set the last hover widget. - this._lastHoverWidget = this._hoverService.showHover(options, focus); - }; - - // If a hover was recently shown, show the hover immediately and skip the fade in animation. - // If not, schedule the hover for display with fade in animation. - if (Date.now() - HoverManager._hoverLeaveTime < 200) { - showHover(true); - } else { - // Set the timeout to show the hover. - this._timeout = setTimeout(() => showHover(false), this._hoverDelay); - } - } - - /** - * Hides a hover. - */ - public hideHover() { - // Clear pending timeout. - if (this._timeout) { - clearTimeout(this._timeout); - this._timeout = undefined; - } - - // If there is a last hover widget, dispose of it and set the hover leave time. - if (this._lastHoverWidget) { - this._lastHoverWidget.dispose(); - this._lastHoverWidget = undefined; - HoverManager._hoverLeaveTime = Date.now(); - } - } -} +import { PositronActionBarHoverManager } from 'vs/platform/positronActionBar/browser/positronActionBarHoverManager'; /** * PositronActionBarServices interface. Defines the set of services that are required by a Positron * action bar. */ export interface PositronActionBarServices { + readonly accessibilityService: IAccessibilityService; readonly commandService: ICommandService; readonly configurationService: IConfigurationService; readonly contextKeyService: IContextKeyService; @@ -179,10 +65,9 @@ export interface PositronActionBarState extends PositronActionBarServices { export const usePositronActionBarState = ( services: PositronActionBarServices ): PositronActionBarState => { - // State hooks. const [menuShowing, setMenuShowing] = useState(false); const [focusableComponents] = useState(new Set()); - const [hoverManager, setHoverManager] = useState(undefined!); + const [hoverManager, setHoverManager] = useState(undefined!); // Main use effect. useEffect(() => { @@ -190,14 +75,15 @@ export const usePositronActionBarState = ( const disposableStore = new DisposableStore(); // Create the hover manager. - setHoverManager(disposableStore.add(new HoverManager( + setHoverManager(disposableStore.add(new PositronActionBarHoverManager( + true, services.configurationService, services.hoverService ))); // Return the cleanup function that will dispose of the disposables. return () => disposableStore.dispose(); - }, [services.configurationService, services.hoverService]); + }, [services.accessibilityService, services.configurationService, services.hoverService]); /** * Appends a command action. diff --git a/src/vs/platform/positronActionBar/common/helpers.ts b/src/vs/platform/positronActionBar/common/helpers.ts new file mode 100644 index 00000000000..2007496de53 --- /dev/null +++ b/src/vs/platform/positronActionBar/common/helpers.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from 'vs/nls'; +import { OS } from 'vs/base/common/platform'; +import { IAction } from 'vs/base/common/actions'; +import { UILabelProvider } from 'vs/base/common/keybindingLabels'; +import { MenuItemAction } from 'vs/platform/actions/common/actions'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; + +/** + * Gets a menu action item from an action. + * @param action The action. + * @returns The menu action item, or undefined if the action is not a menu item action. + */ +export const toMenuActionItem = (action: IAction) => + action instanceof MenuItemAction ? action : undefined; + +/** + * Returns the action tooltip for an action. + * @param contextKeyService The context key service. + * @param keybindingService The keybinding service. + * @param action The action. + * @param includeAlternativeAction A value which indicates whether the alternative action should be + * included. + * @returns The action tooltip. + */ +export const actionTooltip = ( + contextKeyService: IContextKeyService, + keybindingService: IKeybindingService, + action: IAction, + includeAlternativeAction: boolean, +) => { + // Get the keybinding, keybinding label, and tooltip. + const keybinding = keybindingService.lookupKeybinding( + action.id, + contextKeyService + ); + const keybindingLabel = keybinding && keybinding.getLabel(); + const tooltip = action.tooltip || action.label; + + // Set the formatted tooltip. + let formattedTooltip = keybindingLabel ? + localize('titleAndKb', "{0} ({1})", tooltip, keybindingLabel) : + tooltip; + + // Add the alt keybinding and label to the formatted tooltip. + const menuActionItem = toMenuActionItem(action); + if (includeAlternativeAction && menuActionItem && menuActionItem.alt?.enabled) { + // Get the alt keybinding, alt keybinding label, and alt tooltip. + const altKeybinding = keybindingService.lookupKeybinding( + menuActionItem.alt.id, + contextKeyService + ); + const altKeybindingLabel = altKeybinding && altKeybinding.getLabel(); + const altTooltip = menuActionItem.alt.tooltip || menuActionItem.alt.label; + + // Update the formatted tooltip. + formattedTooltip = localize( + 'titleAndKbAndAlt', "{0}\n[{1}] {2}", + formattedTooltip, + UILabelProvider.modifierLabels[OS].altKey, + altKeybindingLabel + ? localize('titleAndKb', "{0} ({1})", altTooltip, altKeybindingLabel) + : altTooltip + ); + } + + // Return the formatted tooltip. + return formattedTooltip; +}; diff --git a/src/vs/workbench/browser/parts/editor/editorActionBar.tsx b/src/vs/workbench/browser/parts/editor/editorActionBar.tsx index 810db434ce5..cc619edcc41 100644 --- a/src/vs/workbench/browser/parts/editor/editorActionBar.tsx +++ b/src/vs/workbench/browser/parts/editor/editorActionBar.tsx @@ -11,19 +11,13 @@ import * as React from 'react'; import { useEffect, useRef, useState } from 'react'; // eslint-disable-line no-duplicate-imports // Other dependencies. -import { localize } from 'vs/nls'; import * as DOM from 'vs/base/browser/dom'; +import { DisposableStore } from 'vs/base/common/lifecycle'; import { isAuxiliaryWindow } from 'vs/base/browser/window'; -import { PositronActionBar } from 'vs/platform/positronActionBar/browser/positronActionBar'; -import { ActionBarRegion } from 'vs/platform/positronActionBar/browser/components/actionBarRegion'; -import { ActionBarButton } from 'vs/platform/positronActionBar/browser/components/actionBarButton'; +import { EditorActionBarFactory } from 'vs/workbench/browser/parts/editor/editorActionBarFactory'; import { PositronActionBarServices } from 'vs/platform/positronActionBar/browser/positronActionBarState'; import { PositronActionBarContextProvider } from 'vs/platform/positronActionBar/browser/positronActionBarContext'; -// Constants. -const PADDING_LEFT = 8; -const PADDING_RIGHT = 8; - /** * EditorActionBarServices interface. */ @@ -34,16 +28,9 @@ interface EditorActionBarServices extends PositronActionBarServices { * EditorActionBarProps interface */ interface EditorActionBarProps extends EditorActionBarServices { + readonly editorActionBarFactory: EditorActionBarFactory; } -/** - * Localized strings. - */ -const moveIntoNewWindowButtonDescription = localize( - 'positron.moveIntoNewWindow', - "Move into New Window" -); - /** * EditorActionBar component. * @returns The rendered component. @@ -53,38 +40,31 @@ export const EditorActionBar = (props: EditorActionBarProps) => { const ref = useRef(undefined!); // State hooks. - const [moveIntoNewWindowDisabled, setMoveIntoNewWindowDisabled] = useState(true); + const [, setRenderMarker] = useState(1); - // Main useEffect. + // Menu manager effect. useEffect(() => { - setMoveIntoNewWindowDisabled(isAuxiliaryWindow(DOM.getWindow(ref.current))); - }, []); + // Create the disposable store for cleanup. + const disposableStore = new DisposableStore(); + + // Add the onDidActionsChange event handler. + disposableStore.add(props.editorActionBarFactory.onDidActionsChange(() => { + // Re-render the component. + setRenderMarker(renderCounter => renderCounter + 1); + })); + + // Return the cleanup function that will dispose of the disposables. + return () => disposableStore.dispose(); + }, [props.editorActionBarFactory]); + + // Determine whether the window is an auxiliary window. + const auxiliaryWindow = ref.current ? isAuxiliaryWindow(DOM.getWindow(ref.current)) : undefined; // Render. return (
- - - - props.commandService.executeCommand( - 'workbench.action.moveEditorToNewWindow' - ) - } - /> - - + {props.editorActionBarFactory.create(auxiliaryWindow)}
); diff --git a/src/vs/workbench/browser/parts/editor/editorActionBarControl.tsx b/src/vs/workbench/browser/parts/editor/editorActionBarControl.tsx index 8e1f89bb596..3ae0a043f0c 100644 --- a/src/vs/workbench/browser/parts/editor/editorActionBarControl.tsx +++ b/src/vs/workbench/browser/parts/editor/editorActionBarControl.tsx @@ -12,8 +12,10 @@ import * as React from 'react'; // Other dependencies. import { Emitter } from 'vs/base/common/event'; import { IHoverService } from 'vs/platform/hover/browser/hover'; +import { IMenuService } from 'vs/platform/actions/common/actions'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { ICommandService } from 'vs/platform/commands/common/commands'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IEditorGroupView } from 'vs/workbench/browser/parts/editor/editor'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; @@ -22,6 +24,8 @@ import { IContextMenuService } from 'vs/platform/contextview/browser/contextView import { EditorActionBar } from 'vs/workbench/browser/parts/editor/editorActionBar'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { EditorActionBarFactory } from 'vs/workbench/browser/parts/editor/editorActionBarFactory'; +import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; /** * Constants. @@ -51,22 +55,30 @@ export class EditorActionBarControl extends Disposable { /** * Constructor. - * @param parent The parent HTML element. + * @param _parent The parent HTML element. + * @param _editorGroup The editor group. + * @param _accessibilityService The accessibility service. * @param _commandService The command service. * @param _configurationService The configuration service. * @param _contextKeyService The context key service. * @param _contextMenuService The context menu service. * @param _hoverService The hover service. * @param _keybindingService The keybinding service. + * @param _menuService The menu service. + * @param _telemetryService The telemetry service. */ constructor( private readonly _parent: HTMLElement, + private readonly _editorGroup: IEditorGroupView, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, @ICommandService private readonly _commandService: ICommandService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IHoverService private readonly _hoverService: IHoverService, @IKeybindingService private readonly _keybindingService: IKeybindingService, + @IMenuService private readonly _menuService: IMenuService, + @ITelemetryService _telemetryService: ITelemetryService, ) { // Call the base class's constructor. super(); @@ -76,35 +88,36 @@ export class EditorActionBarControl extends Disposable { this._container.className = 'editor-action-bar-container'; this._parent.appendChild(this._container); + // Create the editor action bar factory. + const editorActionBarFactory = this._register(new EditorActionBarFactory( + this._editorGroup, + this._contextKeyService, + this._keybindingService, + this._menuService, + )); + // Render the editor action bar component in the editor action bar container. - this._positronReactRenderer = new PositronReactRenderer(this._container); + this._positronReactRenderer = this._register(new PositronReactRenderer(this._container)); this._positronReactRenderer.render( ); } /** - * Dispose method. + * Disposes the editor action bar control. */ override dispose() { - // Dispose the React renderer. - if (this._positronReactRenderer) { - this._positronReactRenderer.dispose(); - this._positronReactRenderer = undefined; - } - - // Remove the container. - if (this._container) { - this._container.remove(); - this._container = undefined; - } + // Remove the editor action bar container. + this._container?.remove(); // Call the base class's dispose method. super.dispose(); @@ -189,17 +202,17 @@ export class EditorActionBarControlFactory { * Constructor. * @param _container The container. * @param _editorGroup The editor group. - * @param configurationService The configuration service. + * @param _configurationService The configuration service. * @param _instantiationService The instantiation service. */ constructor( private readonly _container: HTMLElement, private readonly _editorGroup: IEditorGroupView, - @IConfigurationService configurationService: IConfigurationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, @IInstantiationService private readonly _instantiationService: IInstantiationService ) { // Check if the configuration setting is enabled. If so, create the control. - if (configurationService.getValue(CONFIGURATION_SETTING)) { + if (this._configurationService.getValue(CONFIGURATION_SETTING)) { this.createControl(); } @@ -212,15 +225,17 @@ export class EditorActionBarControlFactory { // Add the onDidChangeConfiguration event listener to listen for changes to the // configuration setting. - this._disposables.add(configurationService.onDidChangeConfiguration(e => { + this._disposables.add(this._configurationService.onDidChangeConfiguration(e => { + // Check if the configuration setting has changed. if (e.affectsConfiguration(CONFIGURATION_SETTING)) { - if (configurationService.getValue(CONFIGURATION_SETTING)) { - // Create the contorl if it doesn't exist. + // Process the change. + if (this._configurationService.getValue(CONFIGURATION_SETTING)) { + // Create the control, if it doesn't exist. if (!this._control) { this.createControl(); } } else { - // Destroy the control if it exists. + // Destroy the control, if it exists. if (this._control) { this._controlDisposables.clear(); this._control = undefined; @@ -250,9 +265,11 @@ export class EditorActionBarControlFactory { * @returns The control. */ private createControl() { + // Create the control. this._control = this._controlDisposables.add(this._instantiationService.createInstance( EditorActionBarControl, - this._container + this._container, + this._editorGroup )); } diff --git a/src/vs/workbench/browser/parts/editor/editorActionBarFactory.tsx b/src/vs/workbench/browser/parts/editor/editorActionBarFactory.tsx new file mode 100644 index 00000000000..e42af984660 --- /dev/null +++ b/src/vs/workbench/browser/parts/editor/editorActionBarFactory.tsx @@ -0,0 +1,340 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +// React. +import * as React from 'react'; + +// Other dependencies. +import { localize } from 'vs/nls'; +import { Emitter } from 'vs/base/common/event'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { IAction, Separator, SubmenuAction } from 'vs/base/common/actions'; +import { IEditorGroupView } from 'vs/workbench/browser/parts/editor/editor'; +import { actionTooltip } from 'vs/platform/positronActionBar/common/helpers'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { PositronActionBar } from 'vs/platform/positronActionBar/browser/positronActionBar'; +import { IMenu, IMenuService, MenuId, MenuItemAction } from 'vs/platform/actions/common/actions'; +import { ActionBarRegion } from 'vs/platform/positronActionBar/browser/components/actionBarRegion'; +import { ActionBarSeparator } from 'vs/platform/positronActionBar/browser/components/actionBarSeparator'; +import { ActionBarMenuButton } from 'vs/platform/positronActionBar/browser/components/actionBarMenuButton'; +import { ActionBarActionButton } from 'vs/platform/positronActionBar/browser/components/actionBarActionButton'; +import { ActionBarCommandButton } from 'vs/platform/positronActionBar/browser/components/actionBarCommandButton'; + +// Constants. +const PADDING_LEFT = 8; +const PADDING_RIGHT = 8; + +/** + * Localized strings. + */ +const positronMoreActionsTooltip = localize( + 'positronMoreActionsTooltip', + "More Actions..." +); +const positronMoreActionsAriaLabel = localize( + 'positronMoreActionsAriaLabel', + "More actions" +); +const positronMoveIntoNewWindowTooltip = localize( + 'positronMoveIntoNewWindowTooltip', + "Move into New Window" +); +const positronMoveIntoNewWindowAriaLabel = localize( + 'positronMoveIntoNewWindowAriaLabel', + "Move into new window" +); + +/** + * Constants. + */ +const CODICON_ID = /^codicon codicon-(.+)$/; + +/** + * SubmenuDescriptor interface. + */ +interface SubmenuDescriptor { + group: string; + action: SubmenuAction; + index: number; +} + +/** +* EditorActionBarFactory class. +*/ +export class EditorActionBarFactory extends Disposable { + //#region Private Properties + + /** + * Gets the menu disposable store. + */ + private readonly _menuDisposableStore = this._register(new DisposableStore()); + + /** + * Gets or sets the editor title menu. + */ + private _editorTitleMenu: IMenu; + + /** + * Gets the onDidActionsChange event emitter. + */ + private readonly _onDidActionsChangeEmitter = this._register(new Emitter()); + + //#endregion Private Properties + + //#region Public Events + + /** + * The onDidActionsChange event. + */ + readonly onDidActionsChange = this._onDidActionsChangeEmitter.event; + + //#endregion Public Events + + //#region Constructor + + /** + * Constructor. + * @param _editorGroup The editor group. + * @param _contextKeyService The context key service. + * @param _keybindingService The keybinding service. + * @param _menuService The menu service. + */ + constructor( + private readonly _editorGroup: IEditorGroupView, + private readonly _contextKeyService: IContextKeyService, + private readonly _keybindingService: IKeybindingService, + private readonly _menuService: IMenuService, + ) { + // Call the base class's constructor. + super(); + + /** + * Creates the editor title menu. + * @returns The editor title menu. + */ + const createEditorTitleMenu = () => { + // Clear the menu disposable store. + this._menuDisposableStore.clear(); + + // If there is an active editor pane, use its scoped context key service, if possible. + // Otherwise, use the editor group's scoped context key service. + const contextKeyService = this._editorGroup.activeEditorPane?.scopedContextKeyService ?? + this._editorGroup.scopedContextKeyService; + + // Create the menu. + const editorTitleMenu = this._menuDisposableStore.add(this._menuService.createMenu( + MenuId.EditorTitle, + contextKeyService, + { + emitEventsForSubmenuChanges: true, + eventDebounceDelay: 0 + } + )); + + // Add the onDidChange event handler. + this._menuDisposableStore.add(editorTitleMenu.onDidChange(() => { + // Create the menu. + this._editorTitleMenu = createEditorTitleMenu(); + + // Raise the onDidActionsChange event. + this._onDidActionsChangeEmitter.fire(); + })); + + // Return the menu. + return editorTitleMenu; + }; + + // Create the menu. + this._editorTitleMenu = createEditorTitleMenu(); + + // Add the onDidActiveEditorChange event handler. + this._register(this._editorGroup.onDidActiveEditorChange(() => { + // Create the menu. + this._editorTitleMenu = createEditorTitleMenu(); + + // Raise the onDidActionsChange event. + this._onDidActionsChangeEmitter.fire(); + })); + } + + //#endregion Constructor + + //#region Public Properties + + /** + * Gets the menu. + */ + get menu() { + return this._editorTitleMenu; + } + + //#endregion Public Properties + + //#region Public Methods + + /** + * Creates the action bar. + * @param auxiliaryWindow A value which indicates whether the window is an auxiliary window. + * @returns The action bar. + */ + create(auxiliaryWindow?: boolean) { + // Break the actions into primary actions, secondary actions, and submenu descriptors. + const primaryActions: IAction[] = []; + const secondaryActions: IAction[] = []; + const submenuDescriptors = new Set(); + for (const [group, actions] of this._editorTitleMenu.getActions()) { + // Determine the target actions. + const targetActions = this.isPrimaryGroup(group) ? primaryActions : secondaryActions; + + // Push a separator between groups. + if (targetActions.length > 0) { + targetActions.push(new Separator()); + } + + // Enumerate the actions of the group. + for (const action of actions) { + // Push the action to the target actions. + const index = targetActions.push(action) - 1; + + // Build the submenu descriptors for inlining below. + if (action instanceof SubmenuAction) { + submenuDescriptors.add({ + group, + action, + index + }); + } + } + } + + // Inline submenus, where possible. + for (const { group, action, index } of submenuDescriptors) { + // Set the target. + const target = this.isPrimaryGroup(group) ? primaryActions : secondaryActions; + + // Inline the submenu, if possible. + if (this.shouldInlineSubmenuAction(group, action)) { + target.splice(index, 1, ...action.actions); + } + } + + // Build the action bar elements. + const elements: JSX.Element[] = []; + for (const action of primaryActions) { + // Process the action. + if (action instanceof Separator) { + // Separator action. + elements.push(); + } else if (action instanceof MenuItemAction) { + // Menu item action. + elements.push(); + } else if (action instanceof SubmenuAction) { + // Submenu action. Get the first action. + const firstAction = action.actions[0]; + + // The first action must be a menu item action. + if (firstAction instanceof MenuItemAction) { + // Extract the icon ID from the class. + const iconIdResult = action.actions[0].class?.match(CODICON_ID); + const iconId = iconIdResult?.length === 2 ? iconIdResult[1] : undefined; + + // Push the action bar menu button. + elements.push( + action.actions} + /> + ); + } + } + } + + // If we know whether we're in an auxiliary window, add the move into new window button. + if (auxiliaryWindow !== undefined) { + elements.push( + + ); + } + + // If there are secondary actions, add the more actions button. Note that the normal + // dropdown arrow is hidden on this button because it uses the ยทยทยท icon. + if (secondaryActions.length) { + elements.push( + secondaryActions} + /> + ); + } + + // Return the elements. + return ( + + + {elements} + + + ); + } + + //#endregion Public Methods + + //#region Private Methods + + /** + * Determines whether a group is the primary group. + * @param group The group. + * @returns true, if the group is the primary group; otherwise, false. + */ + private isPrimaryGroup(group: string) { + return group === 'navigation'; + } + + /** + * Determines whether a submenu action should be inlined. + * @param group The group. + * @param action The submenu action. + * @returns true, if the submenu actions should be inlined; otherwise, false. + */ + private shouldInlineSubmenuAction(group: string, action: SubmenuAction) { + return this.isPrimaryGroup(group) && action.actions.length <= 1; + } + + //#endregion Private Methods +} diff --git a/src/vs/workbench/browser/parts/positronTopActionBar/positronTopActionBarPart.tsx b/src/vs/workbench/browser/parts/positronTopActionBar/positronTopActionBarPart.tsx index c6e15d1a8d5..dc49e4cee89 100644 --- a/src/vs/workbench/browser/parts/positronTopActionBar/positronTopActionBarPart.tsx +++ b/src/vs/workbench/browser/parts/positronTopActionBar/positronTopActionBarPart.tsx @@ -37,6 +37,7 @@ import { IRuntimeStartupService } from 'vs/workbench/services/runtimeStartup/com import { ILanguageRuntimeService } from 'vs/workbench/services/languageRuntime/common/languageRuntimeService'; import { IPositronTopActionBarService } from 'vs/workbench/services/positronTopActionBar/browser/positronTopActionBarService'; import { IPositronTopActionBarContainer, PositronTopActionBar } from 'vs/workbench/browser/parts/positronTopActionBar/positronTopActionBar'; +import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; /** * PositronTopActionBarPart class. @@ -103,6 +104,7 @@ export class PositronTopActionBarPart extends Part implements IPositronTopAction //#region Class Initialization constructor( + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, @ICommandService private readonly commandService: ICommandService, @IConfigurationService private readonly configurationService: IConfigurationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @@ -138,6 +140,7 @@ export class PositronTopActionBarPart extends Part implements IPositronTopAction this.positronReactRenderer = new PositronReactRenderer(this.element); this.positronReactRenderer.render(