From 51b0988dac6613454fdff74ea85ee9dd7ccda1e4 Mon Sep 17 00:00:00 2001 From: Laurens Valk Date: Thu, 2 Apr 2026 11:51:04 +0200 Subject: [PATCH] app: Improve split view behavior. **Move activity buttons to main toolbar** This was on a vscode-style sidebar because we originally thought we would add more. It turns out we will not. Having explorer/settings with the other buttons simplifies the UI. This also makes the buttons not jump around when you open/close the explorer. Also improves on small screens, where the explorer would obscure all buttons. Also lets us remove the blueprint hacks we had to make this work. **Add toggles for sideviews.** This lets us fix several UI/UX problems. It used to be easy to lose the terminal or docs forever. Now you can toggle them back on with a button and conveniently close them rather than dragging them down. **Improve splitters** Switch to react-resizable-panels which lets us control the split as needed. Also when dragging to near closed, it will snap to properly closed. Toggling on then restores normal size. Otherwise it preserves the split value. The collapsed state of this library is made for hiding the docs without destroying them, so we don't need some of the hacks we had to preserve the terminal and docs state. --- package.json | 3 +- src/activities/Activities.test.tsx | 67 ++--- src/activities/Activities.tsx | 150 ++-------- src/activities/activities.scss | 51 +--- src/app/App.test.tsx | 7 +- src/app/App.tsx | 202 +++++++++----- src/app/app.scss | 256 ++++++++++-------- src/app/hooks.ts | 77 ++++++ src/app/translations/en.json | 4 + src/editor/editor.scss | 16 +- src/setupTests.ts | 37 +++ src/toolbar/Toolbar.tsx | 8 +- .../buttons/explorer/ExplorerButton.test.tsx | 56 ++++ .../buttons/explorer/ExplorerButton.tsx | 37 +++ src/toolbar/buttons/explorer/i18n.ts | 19 ++ src/toolbar/buttons/explorer/icon.svg | 4 + .../buttons/explorer/translations/en.json | 7 + .../buttons/settings/SettingsButton.test.tsx | 56 ++++ .../buttons/settings/SettingsButton.tsx | 37 +++ src/toolbar/buttons/settings/i18n.ts | 19 ++ src/toolbar/buttons/settings/icon.svg | 4 + .../buttons/settings/translations/en.json | 7 + yarn.lock | 24 +- 23 files changed, 717 insertions(+), 431 deletions(-) create mode 100644 src/app/hooks.ts create mode 100644 src/toolbar/buttons/explorer/ExplorerButton.test.tsx create mode 100644 src/toolbar/buttons/explorer/ExplorerButton.tsx create mode 100644 src/toolbar/buttons/explorer/i18n.ts create mode 100644 src/toolbar/buttons/explorer/icon.svg create mode 100644 src/toolbar/buttons/explorer/translations/en.json create mode 100644 src/toolbar/buttons/settings/SettingsButton.test.tsx create mode 100644 src/toolbar/buttons/settings/SettingsButton.tsx create mode 100644 src/toolbar/buttons/settings/i18n.ts create mode 100644 src/toolbar/buttons/settings/icon.svg create mode 100644 src/toolbar/buttons/settings/translations/en.json diff --git a/package.json b/package.json index 4c2930aa6..8fbe88457 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "@types/node": "^18.19.14", "@types/react": "^18.2.47", "@types/react-dom": "^18.2.18", - "@types/react-splitter-layout": "^3.0.5", "@types/react-transition-group": "^4.4.10", "@types/redux-logger": "^3.0.12", "@types/semver": "^7.5.6", @@ -99,7 +98,7 @@ "react-popper": "^2.3.0", "react-redux": "^8.1.3", "react-refresh": "^0.14.0", - "react-splitter-layout": "^4.0.0", + "react-resizable-panels": "^4.8.0", "redux": "^4.2.1", "redux-logger": "^3.0.6", "redux-saga": "^1.3.0", diff --git a/src/activities/Activities.test.tsx b/src/activities/Activities.test.tsx index bc99bf5ea..6b4cb5bfc 100644 --- a/src/activities/Activities.test.tsx +++ b/src/activities/Activities.test.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2022-2023 The Pybricks Authors -import { act, cleanup } from '@testing-library/react'; +import { cleanup } from '@testing-library/react'; import React from 'react'; import { testRender } from '../../test'; import Activities from './Activities'; @@ -15,66 +15,37 @@ afterEach(() => { }); describe('Activities', () => { - it('should select explorer by default', () => { + it('should render explorer by default', () => { const [, activities] = testRender(); - const tab = activities.getByRole('tab', { name: 'File Explorer' }); - - expect(tab).toHaveAttribute('aria-selected', 'true'); + expect( + activities.container.querySelector('.pb-activities-tabview'), + ).toBeInTheDocument(); }); - it('should use localStorage for default value', () => { - localStorage.setItem( + it('should render settings when Settings activity is selected', () => { + sessionStorage.setItem( 'activities.selectedActivity', JSON.stringify(Activity.Settings), ); const [, activities] = testRender(); - const tab = activities.getByRole('tab', { name: 'Settings & Help' }); - - expect(tab).toHaveAttribute('aria-selected', 'true'); + expect( + activities.container.querySelector('.pb-activities-tabview'), + ).toBeInTheDocument(); }); - it('should select none when clicking already selected tab', async () => { - const [user, activities] = testRender(); - - const explorerTab = activities.getByRole('tab', { name: 'File Explorer' }); - - for (const tab of activities.getAllByRole('tab')) { - expect(tab).toHaveAttribute( - 'aria-selected', - tab === explorerTab ? 'true' : 'false', - ); - } - - await act(() => user.click(explorerTab)); - - for (const tab of activities.getAllByRole('tab')) { - expect(tab).toHaveAttribute('aria-selected', 'false'); - } - }); - - it('should select new tab when clicking not already selected tab', async () => { - const [user, activities] = testRender(); - - const explorerTab = activities.getByRole('tab', { name: 'File Explorer' }); - const settingsTab = activities.getByRole('tab', { name: 'Settings & Help' }); - - for (const tab of activities.getAllByRole('tab')) { - expect(tab).toHaveAttribute( - 'aria-selected', - tab === explorerTab ? 'true' : 'false', - ); - } + it('should render nothing when no activity is selected', () => { + sessionStorage.setItem( + 'activities.selectedActivity', + JSON.stringify(Activity.None), + ); - await act(() => user.click(settingsTab)); + const [, activities] = testRender(); - for (const tab of activities.getAllByRole('tab')) { - expect(tab).toHaveAttribute( - 'aria-selected', - tab === settingsTab ? 'true' : 'false', - ); - } + expect( + activities.container.querySelector('.pb-activities-tabview'), + ).not.toBeInTheDocument(); }); }); diff --git a/src/activities/Activities.tsx b/src/activities/Activities.tsx index a2b94eceb..51feb9a36 100644 --- a/src/activities/Activities.tsx +++ b/src/activities/Activities.tsx @@ -2,143 +2,33 @@ // Copyright (c) 2022-2023 The Pybricks Authors import './activities.scss'; -import { Icon, Tab, Tabs } from '@blueprintjs/core'; -import { Cog, Document } from '@blueprintjs/icons'; -import React, { useCallback, useEffect, useRef } from 'react'; +import React from 'react'; import Explorer from '../explorer/Explorer'; import Settings from '../settings/Settings'; import { Activity, useActivitiesSelectedActivity } from './hooks'; -import { useI18n } from './i18n'; /** - * React component that acts as a tab control to select activities. + * React component that renders the panel content for the selected activity. */ const Activities: React.FunctionComponent = () => { - const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity(); - const i18n = useI18n(); - - const handleAction = useCallback( - (newActivity: Activity) => { - // if activity is already selected, select none - if (selectedActivity === newActivity) { - setSelectedActivity(Activity.None); - } else { - // otherwise select the new activity - setSelectedActivity(newActivity); - } - }, - [selectedActivity, setSelectedActivity], - ); - - // HACK: fix keyboard focus when no tab is selected - - const tabsRef = useRef(null); - - useEffect(() => { - if (selectedActivity !== Activity.None) { - // all is well - return; - } - - // @ts-expect-error: using private property - const tablist: HTMLDivElement = tabsRef.current?.tablistElement; - - // istanbul-ignore-if: should not happen - if (!tablist) { - return; - } - - const firstTab = tablist - .getElementsByClassName('pb-activities-tablist-tab') - .item(0); - - // istanbul-ignore-if: should not happen - if (!firstTab) { - return; - } - - firstTab.setAttribute('tabindex', '0'); - }, [tabsRef, selectedActivity]); - - // HACK: hoist html title attribute from icon to tab - - useEffect(() => { - // @ts-expect-error: using private property - const tablist: HTMLDivElement = tabsRef.current?.tablistElement; - - // istanbul-ignore-if: should not happen - if (!tablist) { - return; - } - - for (const element of tablist.getElementsByClassName( - 'pb-activities-tablist-tab', - )) { - const title = element.firstElementChild?.getAttribute('title'); - - // istanbul-ignore-if: should not happen - if (!title) { - continue; - } - - element.setAttribute('title', title); - element.firstElementChild?.removeAttribute('title'); - } - }, [tabsRef]); - - useEffect(() => { - // @ts-expect-error: using private property - const tablist: HTMLDivElement = tabsRef.current?.tablistElement; - - // istanbul-ignore-if: should not happen - if (!tablist) { - return; - } - - tablist.setAttribute('aria-label', i18n.translate('title')); - }, [i18n]); - - return ( - - } - /> - } - panel={} - panelClassName="pb-activities-tabview" - onMouseDown={(e) => e.stopPropagation()} - /> - } - /> - } - panel={} - panelClassName="pb-activities-tabview" - onMouseDown={(e) => e.stopPropagation()} - /> - - ); + const [selectedActivity] = useActivitiesSelectedActivity(); + if (selectedActivity === Activity.Explorer) { + return ( +
+ +
+ ); + } + + if (selectedActivity === Activity.Settings) { + return ( +
+ +
+ ); + } + + return null; }; export default Activities; diff --git a/src/activities/activities.scss b/src/activities/activities.scss index 091c657e6..ffe4cc45a 100644 --- a/src/activities/activities.scss +++ b/src/activities/activities.scss @@ -1,45 +1,22 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022 The Pybricks Authors +// Copyright (c) 2022-2026 The Pybricks Authors @use '@blueprintjs/core/lib/scss/variables' as bp; @use '../variables' as pb; -// override bluetprintjs styles -.#{bp.$ns}-tabs.#{bp.$ns}-vertical > { - & .#{bp.$ns}-tab-list .pb-activities-tablist-tab { - margin: bp.$pt-grid-size * 0.6; - padding: unset; - width: unset; - line-height: unset; - } - - & .pb-activities-tabview { - padding: bp.$pt-grid-size * 0.5; - } -} - -.pb-activities { - // TODO: submit upstream patch to allow setting tablist class - &-tablist, - & .#{bp.$ns}-tab-list { - @include pb.background-contrast(6%); - } - - &-tabview { - display: flex; - flex-direction: column; - width: bp.$pt-grid-size * 25; - padding: bp.$pt-grid-size; - @include pb.background-contrast(0%); +.pb-activities-tabview { + display: flex; + flex-direction: column; + width: bp.$pt-grid-size * 25; + padding: bp.$pt-grid-size; + @include pb.background-contrast(0%); + overflow-y: auto; + height: 100%; - // on small screens, make the activity view an overlay instead of inline - @media screen and (max-width: pb.$narrow-screen-limit) { - position: absolute; - top: 0px; - // FIXME: this should be a variable - left: 47px; - height: calc(100% - pb.$status-bar-height); - z-index: bp.$pt-z-index-overlay; - } + // on small screens, make the activity view an overlay instead of inline + @media screen and (max-width: pb.$narrow-screen-limit) { + position: absolute; + height: calc(100% - pb.$status-bar-height); + z-index: bp.$pt-z-index-overlay; } } diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx index d86e7b32f..39e8ee22c 100644 --- a/src/app/App.test.tsx +++ b/src/app/App.test.tsx @@ -42,9 +42,10 @@ it.each([false, true])('should render', (darkMode) => { }); describe('documentation pane', () => { - it('should hide by default', () => { + it('should be collapsed by default', () => { testRender(); - expect(document.querySelector('.pb-show-docs')).toBeNull(); - expect(document.querySelector('.pb-hide-docs')).not.toBeNull(); + // The docs panel starts collapsed (collapsedSize="0%") + const docsPanel = document.querySelector('[data-panel][id="docs"]'); + expect(docsPanel).not.toBeNull(); }); }); diff --git a/src/app/App.tsx b/src/app/App.tsx index fbe67409c..dbb0b5ebc 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,15 +1,16 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020-2026 The Pybricks Authors -import 'react-splitter-layout/lib/index.css'; import './app.scss'; import { Button, Classes, Spinner } from '@blueprintjs/core'; -import { Manual } from '@blueprintjs/icons'; -import React, { useEffect, useState } from 'react'; - -type SideView = 'off' | 'docs'; -import SplitterLayout from 'react-splitter-layout'; -import { useLocalStorage, useTernaryDarkMode } from 'usehooks-ts'; +import { Console, Manual } from '@blueprintjs/icons'; +import React, { useEffect } from 'react'; +import { + Panel, + Group as PanelGroup, + Separator as PanelResizeHandle, +} from 'react-resizable-panels'; +import { useTernaryDarkMode } from 'usehooks-ts'; import Activities from '../activities/Activities'; import DfuWindowsDriverInstallDialog from '../firmware/dfuWindowsDriverInstallDialog/DfuWindowsDriverInstallDialog'; import { InstallPybricksDialog } from '../firmware/installPybricksDialog/InstallPybricksDialog'; @@ -19,6 +20,7 @@ import StatusBar from '../status-bar/StatusBar'; import Toolbar from '../toolbar/Toolbar'; import Tour from '../tour/Tour'; import { docsDefaultPage } from './constants'; +import { useCollapsiblePanel } from './hooks'; import { useI18n } from './i18n'; const Editor = React.lazy(async () => { @@ -73,11 +75,9 @@ const Docs: React.FunctionComponent = () => { const App: React.FunctionComponent = () => { const i18n = useI18n(); const { isDarkMode } = useTernaryDarkMode(); - const [sideView, setSideView] = useState('off'); - const [isDragging, setIsDragging] = useState(false); - const [docsSplit, setDocsSplit] = useLocalStorage('app-docs-split', 30); - const [terminalSplit, setTerminalSplit] = useLocalStorage('app-terminal-split', 30); + const docs = useCollapsiblePanel(false, 30); + const terminal = useCollapsiblePanel(true, 20); // Classes.DARK has to be applied to body element, otherwise it won't // affect portals @@ -105,80 +105,134 @@ const App: React.FunctionComponent = () => { return () => removeEventListener('keydown', listener); }, []); + const sideViewButtons = ( +
+
+ ); + return (
e.preventDefault()}>
- - {/* need a container with position: relative; for SplitterLayout since it uses position: absolute; */} -
- setIsDragging(true)} - onDragEnd={(): void => setIsDragging(false)} - percentage={true} - secondaryInitialSize={docsSplit} - onSecondaryPaneSizeChange={setDocsSplit} - > - -
- - } + +
+ +
+
- + - - - -
+ +
+ + +
+ + } + > + + +
+
+
+ {sideViewButtons} +
+
+ + + + + +
+
+ + + - -
+ + diff --git a/src/app/app.scss b/src/app/app.scss index 97c7318fa..bd95a6193 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -12,194 +12,224 @@ @import '@blueprintjs/icons/lib/css/blueprint-icons.css'; +// Root app container: fills the viewport, stacks toolbar/body/statusbar vertically. .pb-app { width: 100%; height: 100%; - background-color: bp.$pt-app-background-color; display: flex; flex-direction: column; + overflow: hidden; + background-color: bp.$pt-app-background-color; .#{bp.$ns}-dark & { background-color: bp.$pt-dark-app-background-color; } - &-activities { - display: flex; - } - - &-terminal { - height: 100%; - padding-top: 8px; - padding-left: 8px; - background-color: white; - - .#{bp.$ns}-dark & { - background-color: black; - } - } - - &-docs { - height: 100%; - - &-drag-helper { - height: 100%; - width: 100%; - position: absolute; - } - } - & iframe { border: none; } } +// Row containing the activities sidebar and the main content area. .pb-app-body { + flex: 1 1 0; min-height: 0; - flex: 1 1 auto; + display: flex; + flex-direction: row; + overflow: hidden; +} +// Thin activities sidebar on the left, shown when an activity is selected. +.pb-app-activities { + flex: none; + display: flex; + flex-direction: column; + overflow: hidden; + @include pb.background-contrast(0%); +} + +// Row containing the activities panel and the resizable editor/terminal panels. +.pb-app-body-inner { + flex: 1 1 0; + min-height: 0; display: flex; flex-direction: row; + overflow: hidden; +} - .pb-app-main { - min-width: 0; - flex: 1 1 auto; - @include pb.background-contrast(4%); - } +// Main content: toolbar on top, resizable panels below. Fills its Panel. +.pb-app-main { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + @include pb.background-contrast(4%); } +// Wraps the horizontal editor+camera PanelGroup. position: relative anchors +// the absolutely-positioned side-view buttons to this container instead of +// the editor panel, so they stay visible even when the camera is expanded. +.pb-app-editor-area { + position: relative; + width: 100%; + height: 100%; +} + +// The editor
element lives inside a resizable Panel - fill it entirely. .pb-app-editor { + width: 100%; height: 100%; display: flex; flex-direction: column; - position: relative; + overflow: hidden; +} + +// Terminal panel fills its resizable panel. +.pb-app-terminal { + width: 100%; + height: 100%; + padding-top: 8px; + padding-left: 8px; + box-sizing: border-box; + overflow: hidden; + background-color: white; - & .pb-editor { - min-height: 0; - flex: 1 1 auto; + .#{bp.$ns}-dark & { + background-color: black; } } -.pb-app-doc-button { +// Side panel fills its resizable panel. +.pb-app-side-panel { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} + +.pb-app-side-view-buttons { position: absolute; bottom: bp.$pt-grid-size; right: bp.$pt-grid-size; - z-index: bp.$pt-z-index-overlay; + z-index: bp.$pt-z-index-content; + display: flex; + flex-direction: row; + gap: bp.$pt-grid-size * 0.5; + + // match toolbar button appearance: no box-shadow border or active-state shadow + & * { + box-shadow: unset !important; + } } +// Splitter color variables $splitter-background-color: bp.$light-gray2; $splitter-background-color-hover: bp.$gray4; $dark-splitter-background-color: bp.$dark-gray5; $dark-splitter-background-color-hover: bp.$gray2; -$splitter-constrast: -15%; -$dark-splitter-constrast: 20%; +$splitter-contrast: -15%; +$dark-splitter-contrast: 20%; $splitter-color: color.adjust( $splitter-background-color, - $lightness: $splitter-constrast + $lightness: $splitter-contrast ); $splitter-color-hover: color.adjust( $splitter-background-color-hover, - $lightness: $splitter-constrast + $lightness: $splitter-contrast ); $dark-splitter-color: color.adjust( $dark-splitter-background-color, - $lightness: $dark-splitter-constrast + $lightness: $dark-splitter-contrast ); $dark-splitter-color-hover: color.adjust( $dark-splitter-background-color-hover, - $lightness: $dark-splitter-constrast + $lightness: $dark-splitter-contrast ); -.layout-splitter:before { - $width: 1.5; - $height: 2.7; - $radius: 0.4; - - content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-vertical') / ''; - height: bp.$pt-grid-size * $height; - width: bp.$pt-grid-size * $width; - top: calc(50% - bp.$pt-grid-size * $height * 0.5); - left: bp.$pt-grid-size * $width * -0.5 + 2px; - - .splitter-layout-vertical & { - $width: 2.7; - $height: 1.4; - - content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-horizontal') / ''; - height: bp.$pt-grid-size * $height; - width: bp.$pt-grid-size * $width; - top: bp.$pt-grid-size * $height * -0.5 + 2px; - left: calc(50% - bp.$pt-grid-size * $width * 0.5); - } - - font-family: bpi.$blueprint-icons-16-font; - font-size: large; +// Panel resize handles (splitters). +.pb-splitter { display: flex; - position: relative; - border-radius: bp.$pt-grid-size * $radius; - padding: bp.$pt-grid-size * 0.5; align-items: center; justify-content: center; - z-index: bp.$pt-z-index-content; - - color: $splitter-color; background-color: $splitter-background-color; + &:hover { + background-color: $splitter-background-color-hover; + } + .#{bp.$ns}-dark & { - color: $dark-splitter-color; background-color: $dark-splitter-background-color; + + &:hover { + background-color: $dark-splitter-background-color-hover; + } } -} -// make layout splitter match app color scheme -.splitter-layout > .layout-splitter { - color: $splitter-color; - background-color: $splitter-background-color; + // Drag-handle icon via ::before pseudo-element. + &::before { + $radius: 0.4; - &:hover, - &:hover:before { - color: $splitter-color-hover; - background-color: $splitter-background-color-hover; + font-family: bpi.$blueprint-icons-16-font; + font-size: large; + display: flex; + align-items: center; + justify-content: center; + border-radius: bp.$pt-grid-size * $radius; + padding: bp.$pt-grid-size * 0.5; + z-index: bp.$pt-z-index-content; + + color: $splitter-color; + background-color: $splitter-background-color; + + .#{bp.$ns}-dark & { + color: $dark-splitter-color; + background-color: $dark-splitter-background-color; + } } - .#{bp.$ns}-dark & { - color: $dark-splitter-color; - background-color: $dark-splitter-background-color; + &:hover::before { + color: $splitter-color-hover; + background-color: $splitter-background-color-hover; - &:hover, - &:hover:before { + .#{bp.$ns}-dark & { color: $dark-splitter-color-hover; background-color: $dark-splitter-background-color-hover; } } -} -// we want overflow visible for editor overlays but it breaks resizing -// so we can only apply this when not resizing -.splitter-layout:not(.layout-changing) > .layout-pane:has(.pb-app-editor) { - overflow: visible; -} + // Vertical splitter (between left/right panels): thin column, vertical drag icon. + &--vertical { + width: 4px; + cursor: col-resize; -.splitter-layout .layout-pane { - // fix double scroll bars in terminal when resizing - overflow: hidden; + &::before { + $width: 1.5; + $height: 2.7; - &.layout-pane-primary { - // fix resizing window vertically - min-height: 0; + content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-vertical') / + ''; + width: bp.$pt-grid-size * $width; + height: bp.$pt-grid-size * $height; + } } -} -// hide the docs and resize separator -// Use visibility rather than display:none so the iframe keeps its layout and -// scroll position when the docs panel is toggled. -// Collapse the secondary pane and splitter to zero width so they don't -// consume space, while keeping the iframe in the DOM. -div.pb-hide-docs > :not(.layout-pane-primary) { - visibility: hidden; - pointer-events: none; - flex-basis: 0 !important; - width: 0 !important; - min-width: 0 !important; + // Horizontal splitter (between top/bottom panels): thin row, horizontal drag icon. + &--horizontal { + height: 4px; + cursor: row-resize; + + &::before { + $width: 2.7; + $height: 1.4; + + content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-horizontal') / + ''; + width: bp.$pt-grid-size * $width; + height: bp.$pt-grid-size * $height; + } + } } diff --git a/src/app/hooks.ts b/src/app/hooks.ts new file mode 100644 index 000000000..65e9c5d34 --- /dev/null +++ b/src/app/hooks.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Pybricks Authors + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { PanelSize, usePanelRef } from 'react-resizable-panels'; + +/** + * Hook to manage a collapsible panel's size as a percentage (0–100). + * + * This bridges React's declarative state with the library's imperative + * collapse/expand/resize API: + * - `size` tracks the panel's percentage when visible. + * - `visible` controls whether the panel is collapsed or expanded. + * - Setting `visible` to true restores the previous `size`. + * - Setting `size` also updates `visible` accordingly. + * - The `onResize` callback syncs both states when the user drags. + * + * The panel should set `collapsible`, `collapsedSize="0%"`, and a `minSize` + * so that dragging below the minimum auto-collapses. + */ +export function useCollapsiblePanel(initiallyVisble: boolean, initialSize: number) { + const panelRef = usePanelRef(); + const [size, _setSize] = useState(initialSize); + const [visible, _setVisible] = useState(initiallyVisble); + + const setSize = useCallback((s: number) => { + _setSize(s); + _setVisible(s > 0); + }, []); + + const setVisible = useCallback((v: boolean) => _setVisible(v), []); + + useEffect(() => { + const panel = panelRef.current; + if (!panel) { + return; + } + if (!visible) { + if (!panel.isCollapsed()) { + panel.collapse(); + } + } else { + if (panel.isCollapsed()) { + panel.expand(); + } + if (panel.getSize().asPercentage !== size) { + panel.resize(size + '%'); + } + } + }, [size, visible, panelRef]); + + const onResize = useCallback( + (panelSize: PanelSize) => { + const collapsed = panelRef.current?.isCollapsed() ?? false; + if (collapsed) { + // Only update state if the user dragged to collapse + // (visible is still true). If we collapsed programmatically, + // visible is already false and size should be preserved. + if (visible) { + _setVisible(false); + // Reset to initial size so reopening looks normal + // rather than tiny. + _setSize(initialSize); + } + } else { + _setVisible(true); + _setSize(panelSize.asPercentage); + } + }, + [panelRef, initialSize, visible], + ); + + return useMemo( + () => ({ panelRef, size, setSize, visible, setVisible, onResize }), + [panelRef, size, setSize, visible, setVisible, onResize], + ); +} diff --git a/src/app/translations/en.json b/src/app/translations/en.json index e5f384f17..93d96d72b 100644 --- a/src/app/translations/en.json +++ b/src/app/translations/en.json @@ -8,5 +8,9 @@ "docs": { "show": "Show documentation", "hide": "Hide documentation" + }, + "terminal": { + "show": "Show terminal", + "hide": "Hide terminal" } } diff --git a/src/editor/editor.scss b/src/editor/editor.scss index aa12a1ee7..42b7e659c 100644 --- a/src/editor/editor.scss +++ b/src/editor/editor.scss @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2023 The Pybricks Authors +// Copyright (c) 2020-2026 The Pybricks Authors // Custom styling for the Editor control. @@ -21,8 +21,12 @@ } .pb-editor { + flex: 1 1 0; + min-height: 0; + min-width: 0; display: flex; flex-direction: column; + overflow: hidden; &-tablist { flex: none; @@ -58,9 +62,12 @@ } } + // The ContextMenu/tabpanel fills all remaining vertical space. &-tabpanel { + flex: 1 1 0; min-height: 0; - flex: 1 1 auto; + position: relative; + overflow: hidden; } &-welcome { @@ -73,13 +80,10 @@ } } + // The monaco editor div fills the tabpanel entirely. &-monaco { width: 100%; height: 100%; - - .pb-editor-tabpanel.pb-empty > & { - display: none; - } } &-placeholder { diff --git a/src/setupTests.ts b/src/setupTests.ts index 0d2fb2a4e..c3fa3851c 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -38,6 +38,43 @@ if (!Element.prototype.scrollTo) { Element.prototype.scrollTo = jest.fn(); } +// ResizeObserver is not implemented in jsdom but is required by react-resizable-panels +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +// DOMRect is not implemented in jsdom but is required by react-resizable-panels +if (typeof global.DOMRect === 'undefined') { + global.DOMRect = class DOMRect { + x: number; + y: number; + width: number; + height: number; + top: number; + right: number; + bottom: number; + left: number; + constructor(x = 0, y = 0, width = 0, height = 0) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.top = y; + this.right = x + width; + this.bottom = y + height; + this.left = x; + } + toJSON() { + return JSON.stringify(this); + } + static fromRect(rect?: DOMRectInit): DOMRect { + return new DOMRect(rect?.x, rect?.y, rect?.width, rect?.height); + } + } as unknown as typeof DOMRect; +} + Object.defineProperty(global.self, 'crypto', { value: crypto.webcrypto, }); diff --git a/src/toolbar/Toolbar.tsx b/src/toolbar/Toolbar.tsx index 67400c265..dae47ef73 100644 --- a/src/toolbar/Toolbar.tsx +++ b/src/toolbar/Toolbar.tsx @@ -1,13 +1,15 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2025 The Pybricks Authors +// Copyright (c) 2020-2026 The Pybricks Authors import { ButtonGroup } from '@blueprintjs/core'; import React from 'react'; import { useId } from 'react-aria'; import { Toolbar as UtilsToolbar } from '../components/toolbar/Toolbar'; import BluetoothButton from './buttons/bluetooth/BluetoothButton'; +import ExplorerButton from './buttons/explorer/ExplorerButton'; import ReplButton from './buttons/repl/ReplButton'; import RunButton from './buttons/run/RunButton'; +import SettingsButton from './buttons/settings/SettingsButton'; import SponsorButton from './buttons/sponsor/SponsorButton'; import StopButton from './buttons/stop/StopButton'; import UsbButton from './buttons/usb/UsbButton'; @@ -32,6 +34,10 @@ const Toolbar: React.FunctionComponent = () => { className="pb-toolbar" firstFocusableItemId={bluetoothButtonId} > + + + + diff --git a/src/toolbar/buttons/explorer/ExplorerButton.test.tsx b/src/toolbar/buttons/explorer/ExplorerButton.test.tsx new file mode 100644 index 000000000..baefbf9cc --- /dev/null +++ b/src/toolbar/buttons/explorer/ExplorerButton.test.tsx @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 The Pybricks Authors + +import { act, cleanup } from '@testing-library/react'; +import React from 'react'; +import { FocusScope } from 'react-aria'; +import { testRender } from '../../../../test'; +import { Activity } from '../../../activities/hooks'; +import ExplorerButton from './ExplorerButton'; + +afterEach(() => { + cleanup(); + jest.resetAllMocks(); + localStorage.clear(); + sessionStorage.clear(); +}); + +const explorerButtonId = 'test-explorer-button'; + +it('should open explorer activity when clicked', async () => { + sessionStorage.setItem( + 'activities.selectedActivity', + JSON.stringify(Activity.None), + ); + + const [user, button] = testRender( + + + , + ); + + await act(() => user.click(button.getByRole('button', { name: 'File Explorer' }))); + + expect(sessionStorage.getItem('activities.selectedActivity')).toBe( + JSON.stringify(Activity.Explorer), + ); +}); + +it('should close explorer activity when clicked while already active', async () => { + sessionStorage.setItem( + 'activities.selectedActivity', + JSON.stringify(Activity.Explorer), + ); + + const [user, button] = testRender( + + + , + ); + + await act(() => user.click(button.getByRole('button', { name: 'File Explorer' }))); + + expect(sessionStorage.getItem('activities.selectedActivity')).toBe( + JSON.stringify(Activity.None), + ); +}); diff --git a/src/toolbar/buttons/explorer/ExplorerButton.tsx b/src/toolbar/buttons/explorer/ExplorerButton.tsx new file mode 100644 index 000000000..c36b0b61d --- /dev/null +++ b/src/toolbar/buttons/explorer/ExplorerButton.tsx @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Pybricks Authors + +import React from 'react'; +import { Activity, useActivitiesSelectedActivity } from '../../../activities/hooks'; +import ActionButton, { ActionButtonProps } from '../../ActionButton'; +import { useI18n } from './i18n'; +import icon from './icon.svg'; + +type ExplorerButtonProps = Pick; + +const ExplorerButton: React.FunctionComponent = ({ id }) => { + const i18n = useI18n(); + const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity(); + + return ( + + setSelectedActivity( + selectedActivity === Activity.Explorer + ? Activity.None + : Activity.Explorer, + ) + } + /> + ); +}; + +export default ExplorerButton; diff --git a/src/toolbar/buttons/explorer/i18n.ts b/src/toolbar/buttons/explorer/i18n.ts new file mode 100644 index 000000000..43b1db152 --- /dev/null +++ b/src/toolbar/buttons/explorer/i18n.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 The Pybricks Authors + +import { useI18n as useShopifyI18n } from '@shopify/react-i18n'; +import type { TypedI18n } from '../../../i18n'; + +type Translations = { + label: string; + tooltip: { + show: string; + hide: string; + }; +}; + +export function useI18n(): TypedI18n { + // istanbul ignore next: babel-loader rewrites this line + const [i18n] = useShopifyI18n(); + return i18n; +} diff --git a/src/toolbar/buttons/explorer/icon.svg b/src/toolbar/buttons/explorer/icon.svg new file mode 100644 index 000000000..208867271 --- /dev/null +++ b/src/toolbar/buttons/explorer/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/toolbar/buttons/explorer/translations/en.json b/src/toolbar/buttons/explorer/translations/en.json new file mode 100644 index 000000000..a9c553ec6 --- /dev/null +++ b/src/toolbar/buttons/explorer/translations/en.json @@ -0,0 +1,7 @@ +{ + "label": "File Explorer", + "tooltip": { + "show": "Show file explorer", + "hide": "Hide file explorer" + } +} diff --git a/src/toolbar/buttons/settings/SettingsButton.test.tsx b/src/toolbar/buttons/settings/SettingsButton.test.tsx new file mode 100644 index 000000000..b20e07ca9 --- /dev/null +++ b/src/toolbar/buttons/settings/SettingsButton.test.tsx @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 The Pybricks Authors + +import { act, cleanup } from '@testing-library/react'; +import React from 'react'; +import { FocusScope } from 'react-aria'; +import { testRender } from '../../../../test'; +import { Activity } from '../../../activities/hooks'; +import SettingsButton from './SettingsButton'; + +afterEach(() => { + cleanup(); + jest.resetAllMocks(); + localStorage.clear(); + sessionStorage.clear(); +}); + +const settingsButtonId = 'test-settings-button'; + +it('should open settings activity when clicked', async () => { + sessionStorage.setItem( + 'activities.selectedActivity', + JSON.stringify(Activity.None), + ); + + const [user, button] = testRender( + + + , + ); + + await act(() => user.click(button.getByRole('button', { name: 'Settings' }))); + + expect(sessionStorage.getItem('activities.selectedActivity')).toBe( + JSON.stringify(Activity.Settings), + ); +}); + +it('should close settings activity when clicked while already active', async () => { + sessionStorage.setItem( + 'activities.selectedActivity', + JSON.stringify(Activity.Settings), + ); + + const [user, button] = testRender( + + + , + ); + + await act(() => user.click(button.getByRole('button', { name: 'Settings' }))); + + expect(sessionStorage.getItem('activities.selectedActivity')).toBe( + JSON.stringify(Activity.None), + ); +}); diff --git a/src/toolbar/buttons/settings/SettingsButton.tsx b/src/toolbar/buttons/settings/SettingsButton.tsx new file mode 100644 index 000000000..ed2f0dc66 --- /dev/null +++ b/src/toolbar/buttons/settings/SettingsButton.tsx @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Pybricks Authors + +import React from 'react'; +import { Activity, useActivitiesSelectedActivity } from '../../../activities/hooks'; +import ActionButton, { ActionButtonProps } from '../../ActionButton'; +import { useI18n } from './i18n'; +import icon from './icon.svg'; + +type SettingsButtonProps = Pick; + +const SettingsButton: React.FunctionComponent = ({ id }) => { + const i18n = useI18n(); + const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity(); + + return ( + + setSelectedActivity( + selectedActivity === Activity.Settings + ? Activity.None + : Activity.Settings, + ) + } + /> + ); +}; + +export default SettingsButton; diff --git a/src/toolbar/buttons/settings/i18n.ts b/src/toolbar/buttons/settings/i18n.ts new file mode 100644 index 000000000..43b1db152 --- /dev/null +++ b/src/toolbar/buttons/settings/i18n.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 The Pybricks Authors + +import { useI18n as useShopifyI18n } from '@shopify/react-i18n'; +import type { TypedI18n } from '../../../i18n'; + +type Translations = { + label: string; + tooltip: { + show: string; + hide: string; + }; +}; + +export function useI18n(): TypedI18n { + // istanbul ignore next: babel-loader rewrites this line + const [i18n] = useShopifyI18n(); + return i18n; +} diff --git a/src/toolbar/buttons/settings/icon.svg b/src/toolbar/buttons/settings/icon.svg new file mode 100644 index 000000000..2de9db518 --- /dev/null +++ b/src/toolbar/buttons/settings/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/toolbar/buttons/settings/translations/en.json b/src/toolbar/buttons/settings/translations/en.json new file mode 100644 index 000000000..ad5a6fa15 --- /dev/null +++ b/src/toolbar/buttons/settings/translations/en.json @@ -0,0 +1,7 @@ +{ + "label": "Settings", + "tooltip": { + "show": "Show settings and help", + "hide": "Hide settings and help" + } +} diff --git a/yarn.lock b/yarn.lock index 4f49ce27c..c60a9b5a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2994,7 +2994,6 @@ __metadata: "@types/node": ^18.19.14 "@types/react": ^18.2.47 "@types/react-dom": ^18.2.18 - "@types/react-splitter-layout": ^3.0.5 "@types/react-transition-group": ^4.4.10 "@types/redux-logger": ^3.0.12 "@types/semver": ^7.5.6 @@ -3075,7 +3074,7 @@ __metadata: react-popper: ^2.3.0 react-redux: ^8.1.3 react-refresh: ^0.14.0 - react-splitter-layout: ^4.0.0 + react-resizable-panels: ^4.8.0 redux: ^4.2.1 redux-logger: ^3.0.6 redux-saga: ^1.3.0 @@ -5341,15 +5340,6 @@ __metadata: languageName: node linkType: hard -"@types/react-splitter-layout@npm:^3.0.5": - version: 3.0.5 - resolution: "@types/react-splitter-layout@npm:3.0.5" - dependencies: - "@types/react": "*" - checksum: 9c8e9cc7ac568a3f5c7a1237725ce084d4c0747f91f7da359b9d5bf2439b33c99f3b90223443854d9602af84a1fad1ffe159ac0a2b77d4a42588e17044c136ec - languageName: node - linkType: hard - "@types/react-transition-group@npm:^4.4.10": version: 4.4.10 resolution: "@types/react-transition-group@npm:4.4.10" @@ -14778,13 +14768,13 @@ __metadata: languageName: node linkType: hard -"react-splitter-layout@npm:^4.0.0": - version: 4.0.0 - resolution: "react-splitter-layout@npm:4.0.0" +"react-resizable-panels@npm:^4.8.0": + version: 4.8.0 + resolution: "react-resizable-panels@npm:4.8.0" peerDependencies: - prop-types: ^15.5.0 - react: ^15.5.0 || ^16.0.0 - checksum: 52fa6ec01f18df0938687089d85cfdf1950f04c7b611d973edc6fc6b553d1844b414c6d92e21e7de8043a29b21faca9a31b87457328bca7c424493db2c866700 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + checksum: 387f54de46e0727539912878279046816374438f08f0ca2c31ded71a40a65bc68cbab592ba7410469737f3ae120a7c973caebebd19d9dc65ae29019e94c8b93c languageName: node linkType: hard