Migrate to new split sidebar from accordion-like panes (#2063)

* Split ModelingSidebar out into own component

* Consolidate all ModelingPane components and config

* Make ModelingSidebar a directory of components and config

* Remove unused components

* Proper pane styling

* Make tooltip configurable to visually appear on hover only

* Remove debug panel from App

* Fix current tests

* Rename to more intuitive names

* Fix useEffect loop bug with showDebugPanel

* Fix snapshot tests

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* Rerun CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* Merge branch 'main' into franknoirot/sidebar

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* Rerun CI

* Maybe some flakiness in the validation initScripts?

* Avoid test flakiness by waiting for more signals that loading is completed

* Don't assert, just wait for the element to be enabled

* Don't let users accidentally click the gap between the pane and the side of the window

* Firm up extrude from command bar test

* Get rid of unused imports

* Add setting to disable blinking cursor (#2065)

* Add support for "current" marker in command bar for boolean settings

* Add a cursorBlinking setting

* Rename setting to blinkingCursor, honor it in the UI

* Fix scroll layout bug in settings modal

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* Rerun CI

* CSS tweaks

* Allow settings hotkey within KclEditorPane

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* Rerun CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* Rerun CI

* Ensure the KCL code panel is closed for camera movement test

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* Make sure that the camera position inputs are ready to be read from

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* Remove repeat awaits

* Make camera position fields in debug pane update when the pane is initialized

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* Undo that CameraControls change because it made other things weird

* retry fixing camera move test

* Fix race condition where cam setting cam position parts were overwriting each other

* Rerun CI

* Rerun CI

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2024-04-15 12:04:17 -04:00
committed by GitHub
parent fdadd059d6
commit 3fdf7bd45e
48 changed files with 927 additions and 706 deletions

View File

@ -145,6 +145,7 @@ test('Can moving camera', async ({ page, context }) => {
await page.goto('/')
await u.waitForAuthSkipAppStart()
await u.openAndClearDebugPanel()
await u.closeKclCodePanel()
const camPos: [number, number, number] = [0, 85, 85]
const bakeInRetries = async (
@ -178,6 +179,8 @@ test('Can moving camera', async ({ page, context }) => {
}, 300)
await u.openAndClearDebugPanel()
await page.getByTestId('cam-x-position').isVisible()
const vals = await Promise.all([
page.getByTestId('cam-x-position').inputValue(),
page.getByTestId('cam-y-position').inputValue(),
@ -342,7 +345,11 @@ test('executes on load', async ({ page }) => {
await u.waitForAuthSkipAppStart()
// expand variables section
await page.getByText('Variables').click()
const variablesTabButton = page.getByRole('tab', {
name: 'Variables',
exact: false,
})
await variablesTabButton.click()
// can find part001 in the variables summary (pretty-json-container, makes sure we're not looking in the code editor)
// part001 only shows up in the variables summary if it's been executed
@ -366,7 +373,11 @@ test('re-executes', async ({ page }) => {
await page.goto('/')
await u.waitForAuthSkipAppStart()
await page.getByText('Variables').click()
const variablesTabButton = page.getByRole('tab', {
name: 'Variables',
exact: false,
})
await variablesTabButton.click()
// expect to see "myVar:5"
await expect(
page.locator('.pretty-json-container >> text=myVar:5')
@ -538,10 +549,13 @@ test('Auto complete works', async ({ page }) => {
test('Stored settings are validated and fall back to defaults', async ({
page,
context,
}) => {
const u = getUtils(page)
// Override beforeEach test setup
// with corrupted settings
await page.addInitScript(
await context.addInitScript(
async ({ settingsKey, settings }) => {
localStorage.setItem(settingsKey, settings)
},
@ -553,6 +567,7 @@ test('Stored settings are validated and fall back to defaults', async ({
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
// Check the settings were reset
const storedSettings = TOML.parse(
@ -876,8 +891,7 @@ test.describe('Command bar tests', () => {
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
`
const distance = sqrt(20)
`const distance = sqrt(20)
const part001 = startSketchOn('-XZ')
|> startProfileAt([-6.95, 4.98], %)
|> line([25.1, 0.41], %)
@ -896,15 +910,13 @@ test.describe('Command bar tests', () => {
// Make sure the stream is up
await u.openDebugPanel()
await u.expectCmdLog('[data-message-type="execution-done"]')
await u.closeDebugPanel()
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled()
await page.getByText('|> startProfileAt([-6.95, 4.98], %)').click()
await expect(
page.getByRole('button', { name: 'Extrude' })
).not.toBeDisabled()
await u.clearCommandLogs()
await page.getByText('|> line([0.73, -14.93], %)').click()
await page.getByRole('button', { name: 'Extrude' }).isEnabled()
let cmdSearchBar = page.getByPlaceholder('Search commands')
await page.keyboard.press('Meta+K')
@ -922,23 +934,25 @@ test.describe('Command bar tests', () => {
await expect(page.getByPlaceholder('Variable name')).toHaveValue(
'distance001'
)
await expect(page.getByRole('button', { name: 'Continue' })).toBeEnabled()
await page.getByRole('button', { name: 'Continue' }).click()
const continueButton = page.getByRole('button', { name: 'Continue' })
const submitButton = page.getByRole('button', { name: 'Submit command' })
await continueButton.click()
// Review step and argument hotkeys
await expect(
page.getByRole('button', { name: 'Submit command' })
).toBeEnabled()
await expect(submitButton).toBeEnabled()
await page.keyboard.press('Backspace')
// Assert we're back on the distance step
await expect(
page.getByRole('button', { name: 'Distance 12', exact: false })
).toBeDisabled()
await page.keyboard.press('Enter')
await expect(page.getByText('Confirm Extrude')).toBeVisible()
await continueButton.click()
await submitButton.click()
// Check that the code was updated
await page.keyboard.press('Enter')
await u.waitForCmdReceive('extrude')
// Unfortunately this indentation seems to matter for the test
await expect(page.locator('.cm-content')).toHaveText(
`const distance = sqrt(20)

View File

@ -382,11 +382,11 @@ test('extrude on each default plane should be stable', async ({
await u.expectCmdLog('[data-message-type="execution-done"]')
await u.clearAndCloseDebugPanel()
await page.getByText('Code').click()
await u.closeKclCodePanel()
await expect(page).toHaveScreenshot({
maxDiffPixels: 100,
})
await page.getByText('Code').click()
await u.openKclCodePanel()
}
await runSnapshotsForOtherPlanes('XY')
await runSnapshotsForOtherPlanes('-XY')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -44,26 +44,44 @@ async function waitForDefaultPlanesToBeVisible(page: Page) {
)
}
async function openDebugPanel(page: Page) {
const isOpen =
(await page
.locator('[data-testid="debug-panel"]')
?.getAttribute('open')) === ''
async function openKclCodePanel(page: Page) {
const paneLocator = page.getByRole('tab', { name: 'KCL Code', exact: false })
const isOpen = (await paneLocator?.getAttribute('aria-selected')) === 'true'
if (!isOpen) {
await page.getByText('Debug').click()
await page.getByTestId('debug-panel').and(page.locator('[open]')).waitFor()
await paneLocator.click()
await paneLocator.and(page.locator('[aria-selected="true"]')).waitFor()
}
}
async function closeKclCodePanel(page: Page) {
const paneLocator = page.getByRole('tab', { name: 'KCL Code', exact: false })
const isOpen = (await paneLocator?.getAttribute('aria-selected')) === 'true'
if (isOpen) {
await paneLocator.click()
await paneLocator
.and(page.locator(':not([aria-selected="true"])'))
.waitFor()
}
}
async function openDebugPanel(page: Page) {
const debugLocator = page.getByRole('tab', { name: 'Debug', exact: false })
const isOpen = (await debugLocator?.getAttribute('aria-selected')) === 'true'
if (!isOpen) {
await debugLocator.click()
await debugLocator.and(page.locator('[aria-selected="true"]')).waitFor()
}
}
async function closeDebugPanel(page: Page) {
const isOpen =
(await page.getByTestId('debug-panel')?.getAttribute('open')) === ''
const debugLocator = page.getByRole('tab', { name: 'Debug', exact: false })
const isOpen = (await debugLocator?.getAttribute('aria-selected')) === 'true'
if (isOpen) {
await page.getByText('Debug').click()
await page
.getByTestId('debug-panel')
.and(page.locator(':not([open])'))
await debugLocator.click()
await debugLocator
.and(page.locator(':not([aria-selected="true"])'))
.waitFor()
}
}
@ -81,20 +99,19 @@ export function getUtils(page: Page) {
removeCurrentCode: () => removeCurrentCode(page),
sendCustomCmd: (cmd: EngineCommand) => sendCustomCmd(page, cmd),
updateCamPosition: async (xyz: [number, number, number]) => {
const fillInput = async () => {
await page.fill('[data-testid="cam-x-position"]', String(xyz[0]))
await page.fill('[data-testid="cam-y-position"]', String(xyz[1]))
await page.fill('[data-testid="cam-z-position"]', String(xyz[2]))
const fillInput = async (axis: 'x' | 'y' | 'z', value: number) => {
await page.fill(`[data-testid="cam-${axis}-position"]`, String(value))
await page.waitForTimeout(100)
}
await fillInput()
await page.waitForTimeout(100)
await fillInput()
await page.waitForTimeout(100)
await fillInput()
await page.waitForTimeout(100)
await fillInput('x', xyz[0])
await fillInput('y', xyz[1])
await fillInput('z', xyz[2])
},
clearCommandLogs: () => clearCommandLogs(page),
expectCmdLog: (locatorStr: string) => expectCmdLog(page, locatorStr),
openKclCodePanel: () => openKclCodePanel(page),
closeKclCodePanel: () => closeKclCodePanel(page),
openDebugPanel: () => openDebugPanel(page),
closeDebugPanel: () => closeDebugPanel(page),
openAndClearDebugPanel: async () => {

View File

@ -1,22 +1,12 @@
import { useCallback, MouseEventHandler, useEffect, useRef } from 'react'
import { DebugPanel } from './components/DebugPanel'
import { MouseEventHandler, useEffect, useRef } from 'react'
import { uuidv4 } from 'lib/utils'
import { PaneType, useStore } from './useStore'
import { Logs, KCLErrors } from './components/Logs'
import { CollapsiblePanel } from './components/CollapsiblePanel'
import { MemoryPanel } from './components/MemoryPanel'
import { useStore } from './useStore'
import { useHotKeyListener } from './hooks/useHotKeyListener'
import { Stream } from './components/Stream'
import ModalContainer from 'react-modal-promise'
import { EngineCommand } from './lang/std/engineConnection'
import { throttle } from './lib/utils'
import { AppHeader } from './components/AppHeader'
import { Resizable } from 're-resizable'
import {
faCode,
faCodeCommit,
faSquareRootVariable,
} from '@fortawesome/free-solid-svg-icons'
import { useHotkeys } from 'react-hotkeys-hook'
import { getNormalisedCoordinates } from './lib/utils'
import { useLoaderData, useNavigate } from 'react-router-dom'
@ -24,9 +14,6 @@ import { type IndexLoaderData } from 'lib/types'
import { paths } from 'lib/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { onboardingPaths } from 'routes/Onboarding/paths'
import { CodeMenu } from 'components/CodeMenu'
import { TextEditor } from 'components/TextEditor'
import { Themes, getSystemTheme } from 'lib/theme'
import { useEngineConnectionSubscriptions } from 'hooks/useEngineConnectionSubscriptions'
import { engineCommandManager } from 'lib/singletons'
import { useModelingContext } from 'hooks/useModelingContext'
@ -34,6 +21,7 @@ import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
import { isTauri } from 'lib/isTauri'
import { useLspContext } from 'components/LspProvider'
import { useRefreshSettings } from 'hooks/useRefreshSettings'
import { ModelingSidebar } from 'components/ModelingSidebar/ModelingSidebar'
export function App() {
useRefreshSettings(paths.FILE + 'SETTINGS')
@ -52,17 +40,9 @@ export function App() {
}, [projectName, projectPath])
useHotKeyListener()
const {
buttonDownInStream,
openPanes,
setOpenPanes,
didDragInStream,
streamDimensions,
setHtmlRef,
} = useStore((s) => ({
const { buttonDownInStream, didDragInStream, streamDimensions, setHtmlRef } =
useStore((s) => ({
buttonDownInStream: s.buttonDownInStream,
openPanes: s.openPanes,
setOpenPanes: s.setOpenPanes,
didDragInStream: s.didDragInStream,
streamDimensions: s.streamDimensions,
setHtmlRef: s.setHtmlRef,
@ -74,27 +54,10 @@ export function App() {
const { settings } = useSettingsAuthContext()
const {
modeling: { showDebugPanel },
app: { theme, onboardingStatus },
app: { onboardingStatus },
} = settings.context
const { state, send } = useModelingContext()
const editorTheme =
theme.current === Themes.System ? getSystemTheme() : theme.current
// Pane toggling keyboard shortcuts
const togglePane = useCallback(
(newPane: PaneType) =>
openPanes.includes(newPane)
? setOpenPanes(openPanes.filter((p) => p !== newPane))
: setOpenPanes([...openPanes, newPane]),
[openPanes, setOpenPanes]
)
useHotkeys('shift + c', () => togglePane('code'))
useHotkeys('shift + v', () => togglePane('variables'))
useHotkeys('shift + l', () => togglePane('logs'))
useHotkeys('shift + e', () => togglePane('kclErrors'))
useHotkeys('shift + d', () => togglePane('debug'))
useHotkeys('esc', () => send('Cancel'))
useHotkeys('backspace', (e) => {
e.preventDefault()
@ -161,74 +124,8 @@ export function App() {
enableMenu={true}
/>
<ModalContainer />
<Resizable
className={
'pointer-events-none h-full flex flex-col flex-1 z-10 my-2 ml-2 pr-1 transition-opacity transition-duration-75 ' +
+paneOpacity
}
defaultSize={{
width: '550px',
height: 'auto',
}}
minWidth={200}
maxWidth={800}
minHeight={'auto'}
maxHeight={'auto'}
handleClasses={{
right:
'hover:bg-chalkboard-10 hover:dark:bg-chalkboard-110 bg-transparent transition-colors duration-75 transition-ease-out delay-100 ' +
(buttonDownInStream || onboardingStatus.current === 'camera'
? 'pointer-events-none '
: 'pointer-events-auto'),
}}
>
<div
id="code-pane"
className="h-full flex flex-col justify-between pointer-events-none"
>
<CollapsiblePanel
title="Code"
icon={faCode}
className="open:!mb-2"
open={openPanes.includes('code')}
menu={<CodeMenu />}
>
<TextEditor theme={editorTheme} />
</CollapsiblePanel>
<section className="flex flex-col">
<MemoryPanel
theme={editorTheme}
open={openPanes.includes('variables')}
title="Variables"
icon={faSquareRootVariable}
/>
<Logs
theme={editorTheme}
open={openPanes.includes('logs')}
title="Logs"
icon={faCodeCommit}
/>
<KCLErrors
theme={editorTheme}
open={openPanes.includes('kclErrors')}
title="KCL Errors"
iconClassNames={{ bg: 'group-open:bg-destroy-70' }}
/>
</section>
</div>
</Resizable>
<ModelingSidebar paneOpacity={paneOpacity} />
<Stream className="absolute inset-0 z-0" />
{showDebugPanel.current && (
<DebugPanel
title="Debug"
className={
'transition-opacity transition-duration-75 ' +
paneOpacity +
(buttonDownInStream ? ' pointer-events-none' : '')
}
open={openPanes.includes('debug')}
/>
)}
{/* <CamToggle /> */}
</div>
)

View File

@ -110,14 +110,6 @@ export class CameraControls {
}, 400) as any as number
}
// reacts hooks into some of this singleton's properties
reactCameraProperties: ReactCameraProperties = {
type: 'perspective',
fov: 12,
position: [0, 0, 0],
quaternion: [0, 0, 0, 1],
}
setCam = (camProps: ReactCameraProperties) => {
if (
camProps.type === 'perspective' &&
@ -910,6 +902,26 @@ export class CameraControls {
.start()
})
get reactCameraProperties(): ReactCameraProperties {
return {
type: this.isPerspective ? 'perspective' : 'orthographic',
[this.isPerspective ? 'fov' : 'zoom']:
this.camera instanceof PerspectiveCamera
? this.camera.fov
: this.camera.zoom,
position: [
roundOff(this.camera.position.x, 2),
roundOff(this.camera.position.y, 2),
roundOff(this.camera.position.z, 2),
],
quaternion: [
roundOff(this.camera.quaternion.x, 2),
roundOff(this.camera.quaternion.y, 2),
roundOff(this.camera.quaternion.z, 2),
roundOff(this.camera.quaternion.w, 2),
],
}
}
reactCameraPropertiesCallback: (a: ReactCameraProperties) => void = () => {}
setReactCameraPropertiesCallback = (
cb: (a: ReactCameraProperties) => void
@ -937,24 +949,7 @@ export class CameraControls {
isPerspective: this.isPerspective,
target: this.target,
})
this.deferReactUpdate({
type: this.isPerspective ? 'perspective' : 'orthographic',
[this.isPerspective ? 'fov' : 'zoom']:
this.camera instanceof PerspectiveCamera
? this.camera.fov
: this.camera.zoom,
position: [
roundOff(this.camera.position.x, 2),
roundOff(this.camera.position.y, 2),
roundOff(this.camera.position.z, 2),
],
quaternion: [
roundOff(this.camera.quaternion.x, 2),
roundOff(this.camera.quaternion.y, 2),
roundOff(this.camera.quaternion.z, 2),
roundOff(this.camera.quaternion.w, 2),
],
})
this.deferReactUpdate(this.reactCameraProperties)
Object.values(this._camChangeCallbacks).forEach((cb) => cb())
}
getInteractionType = (event: any) =>

View File

@ -126,12 +126,9 @@ const throttled = throttle((a: ReactCameraProperties) => {
}, 1000 / 15)
export const CamDebugSettings = () => {
const [camSettings, setCamSettings] = useState<ReactCameraProperties>({
type: 'perspective',
fov: 12,
position: [0, 0, 0],
quaternion: [0, 0, 0, 1],
})
const [camSettings, setCamSettings] = useState<ReactCameraProperties>(
sceneInfra.camControls.reactCameraProperties
)
const [fov, setFov] = useState(12)
useEffect(() => {

View File

@ -16,7 +16,7 @@ export function AstExplorer() {
const [filterKeys, setFilterKeys] = useState<string[]>(['start', 'end'])
return (
<div className="relative" style={{ width: '300px' }}>
<div id="ast-explorer" className="relative">
<div className="">
filter out keys:<div className="w-2 inline-block"></div>
{['start', 'end', 'type'].map((key) => {
@ -45,7 +45,7 @@ export function AstExplorer() {
setHighlightRange([0, 0])
}}
>
<pre className=" text-xs overflow-y-auto" style={{ width: '300px' }}>
<pre className="text-xs">
<DisplayObj
obj={kclManager.ast}
filterKeys={filterKeys}
@ -109,7 +109,7 @@ function DisplayObj({
<pre
ref={ref}
className={`ml-2 border-l border-violet-600 pl-1 ${
hasCursor ? 'bg-violet-100/25' : ''
hasCursor ? 'bg-violet-100/80 dark:bg-violet-100/25' : ''
}`}
onMouseEnter={(e) => {
setHighlightRange([obj?.start || 0, obj.end])

View File

@ -1,57 +0,0 @@
.panel {
@apply relative z-0;
@apply bg-chalkboard-10/70 backdrop-blur-sm;
}
.header::before,
.header::-webkit-details-marker {
display: none;
}
:global(.dark) .panel {
@apply bg-chalkboard-110/50 backdrop-blur-0;
}
.header {
@apply sticky top-0 z-10 cursor-pointer;
@apply flex items-center justify-between gap-2 w-full p-2;
@apply font-mono text-xs font-bold select-none text-chalkboard-90;
@apply bg-chalkboard-10;
}
.header:not(:last-of-type) {
@apply border-b;
}
:global(.dark) .header {
@apply bg-chalkboard-110 border-b-chalkboard-90 text-chalkboard-30;
}
:global(.dark) .header:not(:last-of-type) {
@apply border-b-2;
}
.panel:first-of-type .header {
@apply rounded-t;
}
.panel:last-of-type .header {
@apply rounded-b;
}
.panel[open] .header {
@apply rounded-t rounded-b-none;
}
.panel[open] {
@apply flex-grow max-h-full h-48 my-1 rounded;
}
.panel[open] + .panel[open],
.panel[open]:first-of-type {
@apply mt-0;
}
.panel[open]:last-of-type {
@apply mb-0;
}

View File

@ -1,76 +0,0 @@
import { IconDefinition } from '@fortawesome/free-solid-svg-icons'
import { ActionIcon } from './ActionIcon'
import styles from './CollapsiblePanel.module.css'
export interface CollapsiblePanelProps
extends React.PropsWithChildren,
React.HTMLAttributes<HTMLDetailsElement> {
title: string
icon?: IconDefinition
open?: boolean
menu?: React.ReactNode
detailsTestId?: string
iconClassNames?: {
bg?: string
icon?: string
}
}
export const PanelHeader = ({
title,
icon,
iconClassNames,
menu,
}: CollapsiblePanelProps) => {
return (
<summary className={styles.header}>
<div className="flex gap-2 items-center flex-1">
<ActionIcon
icon={icon}
className="p-1"
size="sm"
bgClassName={
'dark:!bg-transparent group-open:bg-primary dark:group-open:!bg-primary rounded-sm ' +
(iconClassNames?.bg || '')
}
iconClassName={
'group-open:text-chalkboard-10 ' + (iconClassNames?.icon || '')
}
/>
{title}
</div>
<div className="group-open:opacity-100 opacity-0 group-open:pointer-events-auto pointer-events-none">
{menu}
</div>
</summary>
)
}
export const CollapsiblePanel = ({
title,
icon,
children,
className,
iconClassNames,
menu,
detailsTestId,
...props
}: CollapsiblePanelProps) => {
return (
<details
{...props}
data-testid={detailsTestId}
className={
styles.panel + ' pointer-events-auto group ' + (className || '')
}
>
<PanelHeader
title={title}
icon={icon}
iconClassNames={iconClassNames}
menu={menu}
/>
{children}
</details>
)
}

View File

@ -1,26 +0,0 @@
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
import { AstExplorer } from './AstExplorer'
import { EngineCommands } from './EngineCommands'
import { CamDebugSettings } from 'clientSideScene/ClientSideSceneComp'
export const DebugPanel = ({ className, ...props }: CollapsiblePanelProps) => {
return (
<CollapsiblePanel
{...props}
className={
'!absolute overflow-auto !h-auto bottom-5 right-5 ' + className
}
// header height, top-5, and bottom-5
style={{ maxHeight: 'calc(100% - 3rem - 1.25rem - 1.25rem)' }}
detailsTestId="debug-panel"
>
<section className="p-4 flex flex-col gap-4">
<EngineCommands />
<CamDebugSettings />
<div style={{ height: '400px' }} className="overflow-y-auto">
<AstExplorer />
</div>
</section>
</CollapsiblePanel>
)
}

View File

@ -1,76 +0,0 @@
import ReactJson from 'react-json-view'
import { useEffect } from 'react'
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
import { Themes } from '../lib/theme'
import { useKclContext } from 'lang/KclProvider'
const ReactJsonTypeHack = ReactJson as any
interface LogPanelProps extends CollapsiblePanelProps {
theme?: Exclude<Themes, Themes.System>
}
export const Logs = ({ theme = Themes.Light, ...props }: LogPanelProps) => {
const { logs } = useKclContext()
useEffect(() => {
const element = document.querySelector('.console-tile')
if (element) {
element.scrollTop = element.scrollHeight - element.clientHeight
}
}, [logs])
return (
<CollapsiblePanel {...props}>
<div className="relative w-full">
<div className="absolute inset-0 flex flex-col">
<ReactJsonTypeHack
src={logs}
collapsed={1}
collapseStringsAfterLength={60}
enableClipboard={false}
displayArrayKey={false}
displayDataTypes={false}
displayObjectSize={true}
indentWidth={2}
quotesOnKeys={false}
name={false}
theme={theme === 'light' ? 'rjv-default' : 'monokai'}
/>
</div>
</div>
</CollapsiblePanel>
)
}
export const KCLErrors = ({
theme = Themes.Light,
...props
}: LogPanelProps) => {
const { errors } = useKclContext()
useEffect(() => {
const element = document.querySelector('.console-tile')
if (element) {
element.scrollTop = element.scrollHeight - element.clientHeight
}
}, [errors])
return (
<CollapsiblePanel {...props}>
<div className="h-full relative">
<div className="absolute inset-0 flex flex-col">
<ReactJsonTypeHack
src={errors}
collapsed={1}
collapseStringsAfterLength={60}
enableClipboard={false}
displayArrayKey={false}
displayDataTypes={false}
displayObjectSize={true}
indentWidth={2}
quotesOnKeys={false}
name={false}
theme={theme === 'light' ? 'rjv-default' : 'monokai'}
/>
</div>
</div>
</CollapsiblePanel>
)
}

View File

@ -1,70 +0,0 @@
import ReactJson from 'react-json-view'
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
import { useMemo } from 'react'
import { ProgramMemory, Path, ExtrudeSurface } from '../lang/wasm'
import { Themes } from '../lib/theme'
import { useKclContext } from 'lang/KclProvider'
interface MemoryPanelProps extends CollapsiblePanelProps {
theme?: Exclude<Themes, Themes.System>
}
export const MemoryPanel = ({
theme = Themes.Light,
...props
}: MemoryPanelProps) => {
const { programMemory } = useKclContext()
const ProcessedMemory = useMemo(
() => processMemory(programMemory),
[programMemory]
)
return (
<CollapsiblePanel {...props}>
<div className="h-full relative">
<div className="absolute inset-0 flex flex-col items-start">
<div
className="overflow-y-auto h-full console-tile w-full"
style={{ marginBottom: 36 }}
>
{/* 36px is the height of PanelHeader */}
<ReactJson
src={ProcessedMemory}
collapsed={1}
collapseStringsAfterLength={60}
enableClipboard={false}
displayDataTypes={false}
displayObjectSize={true}
indentWidth={2}
quotesOnKeys={false}
name={false}
theme={theme === 'light' ? 'rjv-default' : 'monokai'}
/>
</div>
</div>
</div>
</CollapsiblePanel>
)
}
export const processMemory = (programMemory: ProgramMemory) => {
const processedMemory: any = {}
Object.keys(programMemory?.root || {}).forEach((key) => {
const val = programMemory.root[key]
if (typeof val.value !== 'function') {
if (val.type === 'SketchGroup') {
processedMemory[key] = val.value.map(({ __geoMeta, ...rest }: Path) => {
return rest
})
} else if (val.type === 'ExtrudeGroup') {
processedMemory[key] = val.value.map(({ ...rest }: ExtrudeSurface) => {
return rest
})
} else {
processedMemory[key] = val.value
}
} else if (key !== 'log') {
processedMemory[key] = '__function__'
}
})
return processedMemory
}

View File

@ -0,0 +1,27 @@
.panel {
@apply relative z-0 rounded-r max-w-full h-full flex-1;
display: grid;
grid-template-rows: auto 1fr;
@apply bg-chalkboard-10/50 backdrop-blur-sm border border-chalkboard-20;
scroll-margin-block-start: 41px;
}
.header::before,
.header::-webkit-details-marker {
display: none;
}
:global(.dark) .panel {
@apply bg-chalkboard-100/50 backdrop-blur-[3px] border-chalkboard-80;
}
.header {
@apply z-10 relative rounded-tr;
@apply flex h-[41px] items-center justify-between gap-2 px-2;
@apply font-mono text-xs font-bold select-none text-chalkboard-90;
@apply bg-chalkboard-10 border-b border-chalkboard-20;
}
:global(.dark) .header {
@apply bg-chalkboard-90 text-chalkboard-30 border-chalkboard-80;
}

View File

@ -0,0 +1,50 @@
import { useStore } from 'useStore'
import styles from './ModelingPane.module.css'
export interface ModelingPaneProps
extends React.PropsWithChildren,
React.HTMLAttributes<HTMLDivElement> {
title: string
Menu?: React.ReactNode | React.FC
detailsTestId?: string
}
export const ModelingPaneHeader = ({
title,
Menu,
}: Pick<ModelingPaneProps, 'title' | 'Menu'>) => {
return (
<div className={styles.header}>
<div className="flex gap-2 items-center flex-1">{title}</div>
{Menu instanceof Function ? <Menu /> : Menu}
</div>
)
}
export const ModelingPane = ({
title,
children,
className,
Menu,
detailsTestId,
...props
}: ModelingPaneProps) => {
const { buttonDownInStream } = useStore((s) => ({
buttonDownInStream: s.buttonDownInStream,
}))
return (
<section
{...props}
data-testid={detailsTestId}
className={
(buttonDownInStream ? 'pointer-events-none ' : 'pointer-events-auto ') +
styles.panel +
' group ' +
(className || '')
}
>
<ModelingPaneHeader title={title} Menu={Menu} />
<div className="relative w-full">{children}</div>
</section>
)
}

View File

@ -0,0 +1,18 @@
import { AstExplorer } from '../../AstExplorer'
import { EngineCommands } from '../../EngineCommands'
import { CamDebugSettings } from 'clientSideScene/ClientSideSceneComp'
export const DebugPane = () => {
return (
<section
data-testid="debug-panel"
className="absolute inset-0 p-2 box-border overflow-auto"
>
<div className="flex flex-col">
<EngineCommands />
<CamDebugSettings />
<AstExplorer />
</div>
</section>
)
}

View File

@ -1,14 +1,14 @@
import { Menu } from '@headlessui/react'
import { PropsWithChildren } from 'react'
import { faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons'
import { ActionIcon } from './ActionIcon'
import styles from './CodeMenu.module.css'
import { ActionIcon } from 'components/ActionIcon'
import styles from './KclEditorMenu.module.css'
import { useConvertToVariable } from 'hooks/useToolbarGuards'
import { editorShortcutMeta } from './TextEditor'
import { editorShortcutMeta } from './KclEditorPane'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { kclManager } from 'lib/singletons'
export const CodeMenu = ({ children }: PropsWithChildren) => {
export const KclEditorMenu = ({ children }: PropsWithChildren) => {
const { enable: convertToVarEnabled, handleClick: handleConvertToVarClick } =
useConvertToVariable()
@ -30,7 +30,7 @@ export const CodeMenu = ({ children }: PropsWithChildren) => {
className="p-1"
size="sm"
bgClassName={
'!bg-transparent hover:!bg-primary/10 hover:dark:!bg-chalkboard-100 ui-active:!bg-primary/10 dark:ui-active:!bg-chalkboard-100 rounded-sm'
'!bg-transparent hover:!bg-primary/10 hover:dark:!bg-chalkboard-100 ui-open:!bg-primary/10 dark:ui-open:!bg-chalkboard-100 rounded-sm'
}
iconClassName={'!text-chalkboard-90 dark:!text-chalkboard-40'}
/>
@ -65,7 +65,7 @@ export const CodeMenu = ({ children }: PropsWithChildren) => {
>
<span>Read the KCL docs</span>
<small>
On GitHub
zoo.dev
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1 align-text-top"
@ -83,7 +83,7 @@ export const CodeMenu = ({ children }: PropsWithChildren) => {
>
<span>KCL samples</span>
<small>
On GitHub
zoo.dev
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1 align-text-top"

View File

@ -3,12 +3,13 @@ import ReactCodeMirror, {
Extension,
ViewUpdate,
SelectionRange,
drawSelection,
} from '@uiw/react-codemirror'
import { TEST } from 'env'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useConvertToVariable } from 'hooks/useToolbarGuards'
import { Themes } from 'lib/theme'
import { Themes, getSystemTheme } from 'lib/theme'
import { useEffect, useMemo, useRef } from 'react'
import { useStore } from 'useStore'
import { processCodeMirrorRanges } from 'lib/selections'
@ -37,15 +38,21 @@ import {
bracketMatching,
indentOnInput,
} from '@codemirror/language'
import { CSSRuleObject } from 'tailwindcss/types/config'
import { useModelingContext } from 'hooks/useModelingContext'
import interact from '@replit/codemirror-interact'
import { engineCommandManager, sceneInfra, kclManager } from 'lib/singletons'
import { useKclContext } from 'lang/KclProvider'
import { ModelingMachineEvent } from 'machines/modelingMachine'
import { NetworkHealthState, useNetworkStatus } from './NetworkHealthIndicator'
import {
NetworkHealthState,
useNetworkStatus,
} from 'components/NetworkHealthIndicator'
import { useHotkeys } from 'react-hotkeys-hook'
import { useLspContext } from './LspProvider'
import { isTauri } from 'lib/isTauri'
import { useNavigate } from 'react-router-dom'
import { paths } from 'lib/paths'
import makeUrlPathRelative from 'lib/makeUrlPathRelative'
import { useLspContext } from 'components/LspProvider'
import { Prec, EditorState } from '@codemirror/state'
import {
closeBrackets,
@ -65,11 +72,14 @@ export const editorShortcutMeta = {
},
}
export const TextEditor = ({
theme,
}: {
theme: Themes.Light | Themes.Dark
}) => {
export const KclEditorPane = () => {
const {
settings: { context },
} = useSettingsAuthContext()
const theme =
context.app.theme.current === Themes.System
? getSystemTheme()
: context.app.theme.current
const { editorView, setEditorView, isShiftDown } = useStore((s) => ({
editorView: s.editorView,
setEditorView: s.setEditorView,
@ -80,6 +90,7 @@ export const TextEditor = ({
const { overallState } = useNetworkStatus()
const isNetworkOkay = overallState === NetworkHealthState.Ok
const { copilotLSP, kclLSP } = useLspContext()
const navigate = useNavigate()
useEffect(() => {
if (typeof window === 'undefined') return
@ -109,6 +120,7 @@ export const TextEditor = ({
const { settings } = useSettingsAuthContext()
const textWrapping = settings.context.textEditor.textWrapping
const cursorBlinking = settings.context.textEditor.blinkingCursor
const { commandBarSend } = useCommandsContext()
const { enable: convertEnabled, handleClick: convertCallback } =
useConvertToVariable()
@ -189,6 +201,9 @@ export const TextEditor = ({
const editorExtensions = useMemo(() => {
const extensions = [
drawSelection({
cursorBlinkRate: cursorBlinking.current ? 1200 : 0,
}),
lineHighlightField,
history(),
closeBrackets(),
@ -208,6 +223,13 @@ export const TextEditor = ({
return false
},
},
{
key: isTauri() ? 'Meta-,' : 'Meta-Shift-,',
run: () => {
navigate(makeUrlPathRelative(paths.SETTINGS))
return false
},
},
{
key: editorShortcutMeta.formatCode.codeMirror,
run: () => {
@ -287,16 +309,14 @@ export const TextEditor = ({
}
return extensions
}, [kclLSP, textWrapping.current, convertCallback])
}, [kclLSP, textWrapping.current, cursorBlinking.current, convertCallback])
return (
<div
id="code-mirror-override"
className="full-height-subtract"
style={{ '--height-subtract': '4.25rem' } as CSSRuleObject}
className={'absolute inset-0 ' + (cursorBlinking.current ? 'blink' : '')}
>
<ReactCodeMirror
className="h-full"
value={code}
extensions={editorExtensions}
onChange={onChange}

View File

@ -0,0 +1,53 @@
import ReactJson from 'react-json-view'
import { useKclContext } from 'lang/KclProvider'
import { useResolvedTheme } from 'hooks/useResolvedTheme'
const ReactJsonTypeHack = ReactJson as any
export const LogsPane = () => {
const theme = useResolvedTheme()
const { logs } = useKclContext()
return (
<div className="overflow-hidden">
<div className="absolute inset-0 p-2 flex flex-col overflow-auto">
<ReactJsonTypeHack
src={logs}
collapsed={1}
collapseStringsAfterLength={60}
enableClipboard={false}
displayArrayKey={false}
displayDataTypes={false}
displayObjectSize={true}
indentWidth={2}
quotesOnKeys={false}
name={false}
theme={theme === 'light' ? 'rjv-default' : 'monokai'}
/>
</div>
</div>
)
}
export const KclErrorsPane = () => {
const theme = useResolvedTheme()
const { errors } = useKclContext()
return (
<div className="overflow-hidden">
<div className="absolute inset-0 p-2 flex flex-col overflow-auto">
<ReactJsonTypeHack
src={errors}
collapsed={1}
collapseStringsAfterLength={60}
enableClipboard={false}
displayArrayKey={false}
displayDataTypes={false}
displayObjectSize={true}
indentWidth={2}
quotesOnKeys={false}
name={false}
theme={theme === 'light' ? 'rjv-default' : 'monokai'}
/>
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
import { processMemory } from './MemoryPanel'
import { enginelessExecutor } from '../lib/testHelpers'
import { initPromise, parse } from '../lang/wasm'
import { processMemory } from './MemoryPane'
import { enginelessExecutor } from '../../../lib/testHelpers'
import { initPromise, parse } from '../../../lang/wasm'
beforeAll(() => initPromise)

View File

@ -0,0 +1,57 @@
import ReactJson from 'react-json-view'
import { useMemo } from 'react'
import { ProgramMemory, Path, ExtrudeSurface } from 'lang/wasm'
import { useKclContext } from 'lang/KclProvider'
import { useResolvedTheme } from 'hooks/useResolvedTheme'
export const MemoryPane = () => {
const theme = useResolvedTheme()
const { programMemory } = useKclContext()
const ProcessedMemory = useMemo(
() => processMemory(programMemory),
[programMemory]
)
return (
<div className="h-full relative">
<div className="absolute inset-0 p-2 flex flex-col items-start">
<div className="overflow-auto h-full w-full pb-12">
<ReactJson
src={ProcessedMemory}
collapsed={1}
collapseStringsAfterLength={60}
enableClipboard={false}
displayDataTypes={false}
displayObjectSize={true}
indentWidth={2}
quotesOnKeys={false}
name={false}
theme={theme === 'light' ? 'rjv-default' : 'monokai'}
/>
</div>
</div>
</div>
)
}
export const processMemory = (programMemory: ProgramMemory) => {
const processedMemory: any = {}
Object.keys(programMemory?.root || {}).forEach((key) => {
const val = programMemory.root[key]
if (typeof val.value !== 'function') {
if (val.type === 'SketchGroup') {
processedMemory[key] = val.value.map(({ __geoMeta, ...rest }: Path) => {
return rest
})
} else if (val.type === 'ExtrudeGroup') {
processedMemory[key] = val.value.map(({ ...rest }: ExtrudeSurface) => {
return rest
})
} else {
processedMemory[key] = val.value
}
} else if (key !== 'log') {
processedMemory[key] = '__function__'
}
})
return processedMemory
}

View File

@ -0,0 +1,67 @@
import {
IconDefinition,
faBugSlash,
faCode,
faCodeCommit,
faExclamationCircle,
faSquareRootVariable,
} from '@fortawesome/free-solid-svg-icons'
import { KclEditorMenu } from 'components/ModelingSidebar/ModelingPanes/KclEditorMenu'
import { CustomIconName } from 'components/CustomIcon'
import { KclEditorPane } from 'components/ModelingSidebar/ModelingPanes/KclEditorPane'
import { ReactNode } from 'react'
import type { PaneType } from 'useStore'
import { MemoryPane } from './MemoryPane'
import { KclErrorsPane, LogsPane } from './LoggingPanes'
import { DebugPane } from './DebugPane'
export type Pane = {
id: PaneType
title: string
icon: CustomIconName | IconDefinition
Content: ReactNode | React.FC
Menu?: ReactNode | React.FC
keybinding: string
}
export const topPanes: Pane[] = [
{
id: 'code',
title: 'KCL Code',
icon: faCode,
Content: KclEditorPane,
keybinding: 'shift + c',
Menu: KclEditorMenu,
},
]
export const bottomPanes: Pane[] = [
{
id: 'variables',
title: 'Variables',
icon: faSquareRootVariable,
Content: MemoryPane,
keybinding: 'shift + v',
},
{
id: 'logs',
title: 'Logs',
icon: faCodeCommit,
Content: LogsPane,
keybinding: 'shift + l',
},
{
id: 'kclErrors',
title: 'KCL Errors',
icon: faExclamationCircle,
Content: KclErrorsPane,
keybinding: 'shift + e',
},
{
id: 'debug',
title: 'Debug',
icon: faBugSlash,
Content: DebugPane,
keybinding: 'shift + d',
},
]

View File

@ -0,0 +1,11 @@
.grid {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: 1fr 1fr;
row-gap: 0.25rem;
align-items: stretch;
position: relative;
padding-block: 1px;
max-width: 100%;
flex: 1 1 0;
}

View File

@ -0,0 +1,213 @@
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Resizable } from 're-resizable'
import { useCallback, useEffect, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { PaneType, useStore } from 'useStore'
import { Tab } from '@headlessui/react'
import { Pane, bottomPanes, topPanes } from './ModelingPanes'
import Tooltip from 'components/Tooltip'
import { ActionIcon } from 'components/ActionIcon'
import styles from './ModelingSidebar.module.css'
import { ModelingPane } from './ModelingPane'
interface ModelingSidebarProps {
paneOpacity: '' | 'opacity-20' | 'opacity-40'
}
export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
const { buttonDownInStream, openPanes } = useStore((s) => ({
buttonDownInStream: s.buttonDownInStream,
openPanes: s.openPanes,
}))
const { settings } = useSettingsAuthContext()
const {
app: { onboardingStatus },
} = settings.context
const pointerEventsCssClass =
buttonDownInStream || onboardingStatus.current === 'camera'
? 'pointer-events-none '
: 'pointer-events-auto'
return (
<Resizable
className={`flex-1 flex flex-col z-10 my-2 pr-1 ${paneOpacity} ${pointerEventsCssClass}`}
defaultSize={{
width: '550px',
height: 'auto',
}}
minWidth={200}
maxWidth={800}
handleClasses={{
right:
(openPanes.length === 0 ? 'hidden ' : 'block ') +
'translate-x-1/2 hover:bg-chalkboard-10 hover:dark:bg-chalkboard-110 bg-transparent transition-colors duration-75 transition-ease-out delay-100 ' +
pointerEventsCssClass,
}}
>
<div className={styles.grid + ' flex-1'}>
<ModelingSidebarSection panes={topPanes} />
<ModelingSidebarSection panes={bottomPanes} alignButtons="end" />
</div>
</Resizable>
)
}
interface ModelingSidebarSectionProps {
panes: Pane[]
alignButtons?: 'start' | 'end'
}
function ModelingSidebarSection({
panes,
alignButtons = 'start',
}: ModelingSidebarSectionProps) {
const { settings } = useSettingsAuthContext()
const showDebugPanel = settings.context.modeling.showDebugPanel
const paneIds = panes.map((pane) => pane.id)
const { openPanes, setOpenPanes } = useStore((s) => ({
openPanes: s.openPanes,
setOpenPanes: s.setOpenPanes,
}))
const foundOpenPane = openPanes.find((pane) => paneIds.includes(pane))
const [currentPane, setCurrentPane] = useState(
foundOpenPane || ('none' as PaneType | 'none')
)
const togglePane = useCallback(
(newPane: PaneType | 'none') => {
if (newPane === 'none') {
setOpenPanes(openPanes.filter((p) => p !== currentPane))
setCurrentPane('none')
} else if (newPane === currentPane) {
setCurrentPane('none')
setOpenPanes(openPanes.filter((p) => p !== newPane))
} else {
setOpenPanes([...openPanes.filter((p) => p !== currentPane), newPane])
setCurrentPane(newPane)
}
},
[openPanes, setOpenPanes, currentPane, setCurrentPane]
)
// Filter out the debug panel if it's not supposed to be shown
// TODO: abstract out for allowing user to configure which panes to show
const filteredPanes = showDebugPanel.current
? panes
: panes.filter((pane) => pane.id !== 'debug')
useEffect(() => {
if (
!showDebugPanel.current &&
currentPane === 'debug' &&
openPanes.includes('debug')
) {
togglePane('debug')
}
}, [showDebugPanel.current, togglePane, openPanes])
return (
<Tab.Group
vertical
selectedIndex={
currentPane === 'none' ? 0 : paneIds.indexOf(currentPane) + 1
}
onChange={(index) => {
const newPane = index === 0 ? 'none' : paneIds[index - 1]
togglePane(newPane)
}}
>
<Tab.List
className={
(alignButtons === 'start'
? 'justify-start self-start'
: 'justify-end self-end') +
(currentPane === 'none'
? ' rounded-r focus-within:!border-primary/50'
: ' border-r-0') +
' p-2 col-start-1 col-span-1 h-fit w-fit flex flex-col items-start gap-2 bg-chalkboard-10 border border-solid border-chalkboard-20 dark:bg-chalkboard-90 dark:border-chalkboard-80 ' +
(openPanes.length === 1 && currentPane === 'none' ? 'pr-0.5' : '')
}
>
<Tab key="none" className="sr-only">
No panes open
</Tab>
{filteredPanes.map((pane) => (
<ModelingPaneButton
key={pane.id}
paneConfig={pane}
currentPane={currentPane}
togglePane={() => togglePane(pane.id)}
/>
))}
</Tab.List>
<Tab.Panels
as="article"
className={
'col-start-2 col-span-1 ' +
(openPanes.length === 1
? currentPane !== 'none'
? `row-start-1 row-end-3`
: `hidden`
: ``)
}
>
<Tab.Panel key="none" />
{filteredPanes.map((pane) => (
<Tab.Panel key={pane.id} className="h-full">
<ModelingPane title={pane.title} Menu={pane.Menu}>
{pane.Content instanceof Function ? (
<pane.Content />
) : (
pane.Content
)}
</ModelingPane>
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
)
}
interface ModelingPaneButtonProps {
paneConfig: Pane
currentPane: PaneType | 'none'
togglePane: () => void
}
function ModelingPaneButton({
paneConfig,
currentPane,
togglePane,
}: ModelingPaneButtonProps) {
useHotkeys(paneConfig.keybinding, togglePane, {
scopes: ['modeling'],
})
return (
<Tab
key={paneConfig.id}
className="pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent p-0 m-0 rounded-sm !outline-none"
onClick={togglePane}
>
<ActionIcon
icon={paneConfig.icon}
className="p-1"
size="sm"
iconClassName={
paneConfig.id === currentPane
? ' !text-chalkboard-10'
: '!text-chalkboard-80 dark:!text-chalkboard-30'
}
bgClassName={
'rounded-sm ' +
(paneConfig.id === currentPane ? '!bg-primary' : '!bg-transparent')
}
/>
<Tooltip position="right" hoverOnly delay={800}>
<span>{paneConfig.title}</span>
<br />
<span className="text-xs capitalize">{paneConfig.keybinding}</span>
</Tooltip>
</Tab>
)
}

View File

@ -1,6 +1,5 @@
import { Popover, Transition } from '@headlessui/react'
import { ActionButton } from './ActionButton'
import { faHome } from '@fortawesome/free-solid-svg-icons'
import { type IndexLoaderData } from 'lib/types'
import { paths } from 'lib/paths'
import { isTauri } from '../lib/isTauri'

View File

@ -116,7 +116,7 @@ export const SettingsAuthProviderBase = ({
},
})
},
toastSuccess: (context, event) => {
toastSuccess: (_, event) => {
const eventParts = event.type.replace(/^set./, '').split('.') as [
keyof typeof settings,
string
@ -211,6 +211,19 @@ export const SettingsAuthProviderBase = ({
)
}, [settingsState.context.app.themeColor.current])
/**
* Update the --cursor-color CSS variable
* based on the setting textEditor.blinkingCursor.current
*/
useEffect(() => {
document.documentElement.style.setProperty(
`--cursor-color`,
settingsState.context.textEditor.blinkingCursor.current
? 'auto'
: 'transparent'
)
}, [settingsState.context.textEditor.blinkingCursor.current])
// Auth machine setup
const [authState, authSend, authActor] = useMachine(authMachine, {
actions: {

View File

@ -94,11 +94,15 @@
position: relative;
}
:is(:hover, :focus-visible, :active) > .tooltip {
:is(:hover, :active) > .tooltip {
opacity: 1;
transition-delay: var(--_delay);
}
:is(:focus-visible) > .tooltip.withFocus {
opacity: 1;
}
:is(:focus, :focus-visible, :focus-within) > .tooltip {
--_delay: 0 !important;
}

View File

@ -15,6 +15,7 @@ interface TooltipProps extends React.PropsWithChildren {
| 'inlineEnd'
className?: string
delay?: number
hoverOnly?: boolean
}
export default function Tooltip({
@ -22,13 +23,16 @@ export default function Tooltip({
position = 'top',
className,
delay = 200,
hoverOnly = false,
}: TooltipProps) {
return (
<div
// @ts-ignore while awaiting merge of this PR for support of "inert" https://github.com/DefinitelyTyped/DefinitelyTyped/pull/60822
inert="true"
role="tooltip"
className={styles.tooltip + ' ' + styles[position] + ' ' + className}
className={`${styles.tooltip} ${hoverOnly ? '' : styles.withFocus} ${
styles[position]
} ${className}`}
style={{ '--_delay': delay + 'ms' } as React.CSSProperties}
>
{children}

View File

@ -0,0 +1,16 @@
import { Themes, getSystemTheme } from 'lib/theme'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
/**
* Resolves the current theme based on the theme setting
* and the system theme if needed.
* @returns {Themes.Light | Themes.Dark}
*/
export function useResolvedTheme() {
const {
settings: { context },
} = useSettingsAuthContext()
return context.app.theme.current === Themes.System
? getSystemTheme()
: context.app.theme.current
}

View File

@ -46,6 +46,15 @@ select {
@apply bg-chalkboard-90;
}
/* We hide the cursor if the user has turned off the textEditor.blinkingCursor setting
* any elements that could present a blinking cursor to the user
*/
input,
textarea,
*[contenteditable] {
caret-color: var(--cursor-color, auto);
}
::-webkit-scrollbar {
@apply w-2 h-2 rounded-sm;
@apply bg-chalkboard-20;
@ -113,32 +122,28 @@ code {
monospace;
}
.full-height-subtract {
--height-subtract: 2.25rem;
height: 100%;
max-height: calc(100% - var(--height-subtract));
}
/*
* The first descendent of the CodeMirror wrapper is the theme,
* but its identifying class can change depending on the theme.
*/
#code-mirror-override > div,
#code-mirror-override .cm-editor {
@apply h-full bg-transparent;
@apply bg-transparent h-full;
}
#code-mirror-override .cm-scroller {
@apply h-full;
}
#code-mirror-override .cm-scroller::-webkit-scrollbar {
@apply h-0;
overflow: auto;
}
#code-mirror-override .cm-activeLine,
#code-mirror-override .cm-activeLineGutter {
@apply bg-liquid-10/50;
@apply bg-primary/20;
}
.dark #code-mirror-override .cm-activeLine,
.dark #code-mirror-override .cm-activeLineGutter {
@apply bg-liquid-80/50;
@apply bg-primary/20;
mix-blend-mode: lighten;
}
#code-mirror-override .cm-gutters {
@ -149,19 +154,29 @@ code {
@apply bg-chalkboard-110/50;
}
#code-mirror-override .cm-content {
@apply caret-primary;
}
.dark #code-mirror-override .cm-content {
@apply caret-chalkboard-10;
}
#code-mirror-override .cm-focused .cm-cursor {
width: 0px;
}
#code-mirror-override .cm-cursor {
display: block;
width: 1ch;
@apply bg-liquid-40 mix-blend-multiply;
animation: blink 2s ease-out infinite;
@apply mix-blend-multiply;
@apply border-l-primary;
}
.dark #code-mirror-override .cm-cursor {
@apply bg-liquid-50;
@apply border-l-chalkboard-10;
}
#code-mirror-override.blink .cm-cursor {
animation: blink 1200ms ease-out infinite;
}
@keyframes blink {
@ -169,8 +184,8 @@ code {
100% {
opacity: 0;
}
15% {
opacity: 0.75;
10% {
opacity: 1;
}
}

View File

@ -15,6 +15,7 @@ import { getPropertyByPath } from 'lib/objectPropertyByPath'
import { buildCommandArgument } from 'lib/createMachineCommand'
import decamelize from 'decamelize'
import { isTauri } from 'lib/isTauri'
import { Setting } from 'lib/settings/initialSettings'
// An array of the paths to all of the settings that have commandConfigs
export const settingsWithCommandConfigs = (
@ -87,11 +88,34 @@ export function createSettingsCommand({
)
return null
const valueArgConfig = {
let valueArgConfig = {
...valueArgPartialConfig,
required: true,
} as CommandArgumentConfig<S['default']>
// If the setting is a boolean, we coerce it into an options input type
if (valueArgConfig.inputType === 'boolean') {
valueArgConfig = {
...valueArgConfig,
inputType: 'options',
options: (cmdBarContext, machineContext) => {
const setting = getPropertyByPath(
machineContext,
type
) as Setting<boolean>
const level = cmdBarContext.argumentsToSubmit.level as SettingsLevel
const isCurrent =
setting[level] === undefined
? setting.getFallback(level) === true
: setting[level] === true
return [
{ name: 'On', value: true, isCurrent },
{ name: 'Off', value: false, isCurrent: !isCurrent },
]
},
}
}
// @ts-ignore - TODO figure out this typing for valueArgConfig
const valueArg = buildCommandArgument(valueArgConfig, context, actor)

View File

@ -151,7 +151,8 @@ export type CommandArgumentConfig<
defaultValue?:
| OutputType
| ((
commandBarContext: ContextFrom<typeof commandBarMachine>
commandBarContext: ContextFrom<typeof commandBarMachine>,
machineContext?: C
) => OutputType)
defaultValueFromContext?: (context: C) => OutputType
}

View File

@ -362,6 +362,17 @@ export function createSettings() {
inputType: 'boolean',
},
}),
/**
* Whether to make the cursor blink in the editor
*/
blinkingCursor: new Setting<boolean>({
defaultValue: true,
description: 'Whether to make the cursor blink in the editor',
validate: (v) => typeof v === 'boolean',
commandConfig: {
inputType: 'boolean',
},
}),
},
/**
* Settings that affect the behavior of project management.

View File

@ -483,7 +483,8 @@ export const commandBarMachine = createMachine(
}
if (
(argConfig.inputType !== 'boolean'
(argConfig.inputType !== 'boolean' &&
argConfig.inputType !== 'options'
? !argValue
: argValue === undefined) &&
isRequired

View File

@ -126,7 +126,7 @@ export const Settings = () => {
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="rounded relative mx-auto bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-3xl w-full max-h-[66vh] shadow-lg flex flex-col gap-8 overflow-hidden">
<Dialog.Panel className="rounded relative mx-auto bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-3xl w-full max-h-[66vh] shadow-lg flex flex-col gap-8">
<div className="p-5 pb-0 flex justify-between items-center">
<h1 className="text-2xl font-bold">Settings</h1>
<button
@ -163,8 +163,11 @@ export const Settings = () => {
</RadioGroup.Option>
)}
</RadioGroup>
<div className="flex flex-grow overflow-hidden items-stretch pl-4 pr-5 pb-5 gap-4">
<div className="flex w-64 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
<div
className="flex-1 grid items-stretch pl-4 pr-5 pb-5 gap-4 overflow-hidden"
style={{ gridTemplateColumns: 'auto 1fr' }}
>
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
{Object.entries(context)
.filter(([_, categorySettings]) =>
// Filter out categories that don't have any non-hidden settings
@ -216,10 +219,8 @@ export const Settings = () => {
About
</button>
</div>
<div
ref={scrollRef}
className="flex flex-col gap-6 px-2 overflow-y-auto"
>
<div className="relative overflow-y-auto">
<div ref={scrollRef} className="flex flex-col gap-6 px-2">
{Object.entries(context)
.filter(([_, categorySettings]) =>
// Filter out categories that don't have any non-hidden settings
@ -383,12 +384,13 @@ export const Settings = () => {
>
our roadmap
</a>
, and start a discussion if you don't see it! Your feedback
will help us prioritize what to build next.
, and start a discussion if you don't see it! Your
feedback will help us prioritize what to build next.
</p>
</div>
</div>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</Dialog>