Compare commits

...

11 Commits

Author SHA1 Message Date
2d350a93ed Implement command bar UI for optional parameters 2023-12-15 00:25:22 -05:00
16dd5aab96 Make stream clickable when panes are collapsed (#1209)
* Make stream clickable when panes are collapsed

* Tweak UI test
2023-12-14 21:50:37 -05:00
bf68a87897 Make web warning less annoying (#1206)
* Put a proper overlay behind the web app warning banner
Resolves #1197

* Add outline to kcma logo in readme
Resolves #1159

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

* retrigger CI

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-12-14 15:48:06 -05:00
c6e97e729a Remove execution-plan crate (#1207)
It's now in https://github.com/KittyCAD/modeling-api/tree/main/execution-plan
2023-12-13 18:21:38 +00:00
d2535bb8c2 Command bar: add extrude command, nonlinear editing, etc (#1204)
* Tweak toaster look and feel

* Add icons, tweak plus icon names

* Rename commandBarMeta to commandBarConfig

* Refactor command bar, add support for icons

* Create a tailwind plugin for aria-pressed button state

* Remove overlay from behind command bar

* Clean up toolbar

* Button and other style tweaks

* Icon tweaks follow-up: make old icons work with new sizing

* Delete unused static icons

* More CSS tweaks

* Small CSS tweak to project sidebar

* Add command bar E2E test

* fumpt

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

* fix typo in a comment

* Fix icon padding (built version only)

* Update onboarding and warning banner icons padding

* Misc minor style fixes

* Get Extrude opening and canceling from command bar

* Iconography tweaks

* Get extrude kind of working

* Refactor command bar config types and organization

* Move command bar configs to be co-located with each other

* Start building a state machine for the command bar

* Start converting command bar to state machine

* Add support for multiple args, confirmation step

* Submission behavior, hotkeys, code organization

* Add new test for extruding from command bar

* Polish step back and selection hotkeys, CSS tweaks

* Loading style tweaks

* Validate selection inputs, polish UX of args re-editing

* Prevent submission with multiple selection on singlular arg

* Remove stray console logs

* Tweak test, CSS nit, remove extrude "result" argument

* Fix linting warnings

* Show Ctrl+/ instead of ⌘K on all platforms but Mac

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

* Add "Enter sketch" to command bar

* fix command bar test

* Fix flaky cmd bar extrude test by waiting for engine select response

* Cover both button labels '⌘K' and 'Ctrl+/' in test

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-12-13 12:49:01 -05:00
b01357b49e Impl composite for Modeling Command types (#1196) 2023-12-12 09:26:36 -06:00
793e3510cc Bump actions/setup-python from 4 to 5 (#1186)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 07:03:39 -05:00
04ae8141c3 Tauri tests on dev when the CI's BUILD_RELEASE is false (#1183)
* Tauri tests on dev when the CI's BUILD_RELEASE is false
Fixes #1182

* Fix bin not here

* Dev token on debug

* Clean up
2023-12-07 06:54:13 -05:00
3ae5393dd7 Prepare command bar to support modeling commands (#1184)
* Tweak toaster look and feel

* Add icons, tweak plus icon names

* Rename commandBarMeta to commandBarConfig

* Refactor command bar, add support for icons

* Create a tailwind plugin for aria-pressed button state

* Remove overlay from behind command bar

* Clean up toolbar

* Button and other style tweaks

* Icon tweaks follow-up: make old icons work with new sizing

* Delete unused static icons

* More CSS tweaks

* Small CSS tweak to project sidebar

* Add command bar E2E test

* fumpt

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

* fix typo in a comment

* Fix icon padding (built version only)

* Update onboarding and warning banner icons padding

* Misc minor style fixes

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-12-06 14:44:13 -05:00
38119d5a3b Execution plans should allow dynamic sized types (#1178)
* Execution plans should allow dynamic sized types

* move Composite impls into their own file
2023-12-05 16:21:29 -06:00
b453b4b453 Update 20/20 snapshots (#1179) 2023-12-05 15:23:58 -06:00
120 changed files with 5428 additions and 3054 deletions

View File

@ -55,7 +55,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v5
- name: Install codespell - name: Install codespell
run: | run: |
python -m pip install codespell python -m pip install codespell
@ -181,6 +181,9 @@ jobs:
cd ../../ cd ../../
cp src/wasm-lib/pkg/wasm_lib_bg.wasm public cp src/wasm-lib/pkg/wasm_lib_bg.wasm public
- name: Run vite build (build:both)
run: yarn vite build --mode ${{ env.BUILD_RELEASE == 'true' && 'production' || 'development' }}
- name: Fix format - name: Fix format
run: yarn fmt run: yarn fmt
@ -250,10 +253,12 @@ jobs:
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: | run: |
cargo install tauri-driver cargo install tauri-driver
source .env.${{ env.BUILD_RELEASE == 'true' && 'production' || 'development' }}
export VITE_KC_API_BASE_URL
xvfb-run yarn test:e2e:tauri xvfb-run yarn test:e2e:tauri
env: env:
E2E_APPLICATION: "./src-tauri/target/${{ env.BUILD_RELEASE == 'true' && 'release' || 'debug' }}/kittycad-modeling" E2E_APPLICATION: "./src-tauri/target/${{ env.BUILD_RELEASE == 'true' && 'release' || 'debug' }}/kittycad-modeling"
KITTYCAD_API_TOKEN: ${{ secrets.KITTYCAD_API_TOKEN }} KITTYCAD_API_TOKEN: ${{ env.BUILD_RELEASE == 'true' && secrets.KITTYCAD_API_TOKEN || secrets.KITTYCAD_API_TOKEN_DEV }}
publish-apps-release: publish-apps-release:

View File

@ -1,4 +1,4 @@
![KittyCAD Modeling App](/public/kcma-logomark.png) ![KittyCAD Modeling App](/public/kcma-logomark-outlined.png)
## KittyCAD Modeling App ## KittyCAD Modeling App

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -8,275 +8,275 @@ property float z
element face 68 element face 68
property list uchar uint vertex_indices property list uchar uint vertex_indices
end_header end_header
0 0 4 0 0 4
0 0 0 0 0 0
0 -1 4 0 -1 4
0 -1 4 0 -1 4
0 0 0 0 0 0
0 -1 0 0 -1 0
0 -1 4 0 -1 4
0 -1 0 0 -1 0
3.0950184 -1 4 3.0950184 -1 4
3.0950184 -1 4 3.0950184 -1 4
0 -1 0 0 -1 0
3.0950184 -1 0 3.0950184 -1 0
3.0950184 -1 4 3.0950184 -1 4
3.0950184 -1 0 3.0950184 -1 0
5.9513144 -3 4 5.9513144 -3 4
5.9513144 -3 4 5.9513144 -3 4
3.0950184 -1 0 3.0950184 -1 0
5.9513144 -3 0 5.9513144 -3 0
5.9513144 -3 4 5.9513144 -3 4
5.9513144 -3 0 5.9513144 -3 0
9.5 -3 4 9.5 -3 4
9.5 -3 4 9.5 -3 4
5.9513144 -3 0 5.9513144 -3 0
9.5 -3 0 9.5 -3 0
9.5 -3 4 9.5 -3 4
9.5 -3 0 9.5 -3 0
9.5 -2.5 4 9.5 -2.5 4
9.5 -2.5 4 9.5 -2.5 4
9.5 -3 0 9.5 -3 0
9.5 -2.5 0 9.5 -2.5 0
9.5 -2.5 4 9.5 -2.5 4
9.5 -2.5 0 9.5 -2.5 0
6.108964 -2.5 4 6.108964 -2.5 4
6.108964 -2.5 4 6.108964 -2.5 4
9.5 -2.5 0 9.5 -2.5 0
6.108964 -2.5 0 6.108964 -2.5 0
3.4311862 -0.625 4 3.4311862 -0.625 4
4.323779 -1.25 4 4.323779 -1.25 4
4.323779 -1.25 0 4.323779 -1.25 0
4.323779 -1.25 4 4.323779 -1.25 4
6.108964 -2.5 4 6.108964 -2.5 4
6.108964 -2.5 0 6.108964 -2.5 0
3.4311862 -0.625 0 3.4311862 -0.625 0
2.5385938 0 0 2.5385938 0 0
2.5385938 0 4 2.5385938 0 4
3.4311862 -0.625 4 3.4311862 -0.625 4
3.4311862 -0.625 0 3.4311862 -0.625 0
2.5385938 0 4 2.5385938 0 4
4.323779 -1.25 4 4.323779 -1.25 4
6.108964 -2.5 0 6.108964 -2.5 0
4.323779 -1.25 0 4.323779 -1.25 0
3.4311862 -0.625 0 3.4311862 -0.625 0
3.4311862 -0.625 4 3.4311862 -0.625 4
4.323779 -1.25 0 4.323779 -1.25 0
3.342784 0.375 4 3.342784 0.375 4
2.5385938 0 4 2.5385938 0 4
2.5385938 0 0 2.5385938 0 0
4.146974 0.75 4 4.146974 0.75 4
3.342784 0.375 4 3.342784 0.375 4
3.342784 0.375 0 3.342784 0.375 0
3.342784 0.375 0 3.342784 0.375 0
4.146974 0.75 0 4.146974 0.75 0
4.146974 0.75 4 4.146974 0.75 4
4.146974 0.75 0 4.146974 0.75 0
5.755354 1.5 0 5.755354 1.5 0
5.755354 1.5 4 5.755354 1.5 4
3.342784 0.375 4 3.342784 0.375 4
2.5385938 0 0 2.5385938 0 0
3.342784 0.375 0 3.342784 0.375 0
5.755354 1.5 4 5.755354 1.5 4
4.146974 0.75 4 4.146974 0.75 4
4.146974 0.75 0 4.146974 0.75 0
5.755354 1.5 4 5.755354 1.5 4
5.755354 1.5 0 5.755354 1.5 0
9.5 1.5 4 9.5 1.5 4
9.5 1.5 4 9.5 1.5 4
5.755354 1.5 0 5.755354 1.5 0
9.5 1.5 0 9.5 1.5 0
9.5 1.5 4 9.5 1.5 4
9.5 1.5 0 9.5 1.5 0
9.5 2 4 9.5 2 4
9.5 2 4 9.5 2 4
9.5 1.5 0 9.5 1.5 0
9.5 2 0 9.5 2 0
9.5 2 4 9.5 2 4
9.5 2 0 9.5 2 0
5.644507 2 4 5.644507 2 4
5.644507 2 4 5.644507 2 4
9.5 2 0 9.5 2 0
5.644507 2 0 5.644507 2 0
5.644507 2 4 5.644507 2 4
5.644507 2 0 5.644507 2 0
3.5 1 4 3.5 1 4
3.5 1 4 3.5 1 4
5.644507 2 0 5.644507 2 0
3.5 1 0 3.5 1 0
3.5 1 4 3.5 1 4
3.5 1 0 3.5 1 0
0 1 4 0 1 4
0 1 4 0 1 4
3.5 1 0 3.5 1 0
0 1 0 0 1 0
0 1 4 0 1 4
0 1 0 0 1 0
0 0 4 0 0 4
0 0 4 0 0 4
0 1 0 0 1 0
0 0 0 0 0 0
3.342784 0.375 0 3.342784 0.375 0
2.5385938 0 0 2.5385938 0 0
3.5 1 0 3.5 1 0
3.4311862 -0.625 0 3.4311862 -0.625 0
4.323779 -1.25 0 4.323779 -1.25 0
3.0950184 -1 0 3.0950184 -1 0
3.342784 0.375 0 3.342784 0.375 0
3.5 1 0 3.5 1 0
4.146974 0.75 0 4.146974 0.75 0
4.323779 -1.25 0 4.323779 -1.25 0
5.9513144 -3 0 5.9513144 -3 0
3.0950184 -1 0 3.0950184 -1 0
0 -1 0 0 -1 0
2.5385938 0 0 2.5385938 0 0
3.0950184 -1 0 3.0950184 -1 0
0 -1 0 0 -1 0
0 0 0 0 0 0
2.5385938 0 0 2.5385938 0 0
9.5 -3 0 9.5 -3 0
6.108964 -2.5 0 6.108964 -2.5 0
9.5 -2.5 0 9.5 -2.5 0
9.5 -3 0 9.5 -3 0
5.9513144 -3 0 5.9513144 -3 0
6.108964 -2.5 0 6.108964 -2.5 0
5.9513144 -3 0 5.9513144 -3 0
4.323779 -1.25 0 4.323779 -1.25 0
6.108964 -2.5 0 6.108964 -2.5 0
5.644507 2 0 5.644507 2 0
5.755354 1.5 0 5.755354 1.5 0
4.146974 0.75 0 4.146974 0.75 0
3.0950184 -1 0 3.0950184 -1 0
2.5385938 0 0 2.5385938 0 0
3.4311862 -0.625 0 3.4311862 -0.625 0
4.146974 0.75 0 4.146974 0.75 0
3.5 1 0 3.5 1 0
5.644507 2 0 5.644507 2 0
9.5 1.5 0 9.5 1.5 0
5.755354 1.5 0 5.755354 1.5 0
9.5 2 0 9.5 2 0
5.755354 1.5 0 5.755354 1.5 0
5.644507 2 0 5.644507 2 0
9.5 2 0 9.5 2 0
2.5385938 0 0 2.5385938 0 0
0 0 0 0 0 0
0 1 0 0 1 0
3.5 1 0 3.5 1 0
2.5385938 0 0 2.5385938 0 0
0 1 0 0 1 0
3.342784 0.375 4 3.342784 0.375 4
3.5 1 4 3.5 1 4
2.5385938 0 4 2.5385938 0 4
4.146974 0.75 4 4.146974 0.75 4
3.5 1 4 3.5 1 4
3.342784 0.375 4 3.342784 0.375 4
3.4311862 -0.625 4 3.4311862 -0.625 4
3.0950184 -1 4 3.0950184 -1 4
4.323779 -1.25 4 4.323779 -1.25 4
4.146974 0.75 4 4.146974 0.75 4
5.755354 1.5 4 5.755354 1.5 4
5.644507 2 4 5.644507 2 4
0 1 4 0 1 4
2.5385938 0 4 2.5385938 0 4
3.5 1 4 3.5 1 4
0 1 4 0 1 4
0 0 4 0 0 4
2.5385938 0 4 2.5385938 0 4
5.644507 2 4 5.644507 2 4
5.755354 1.5 4 5.755354 1.5 4
9.5 2 4 9.5 2 4
9.5 2 4 9.5 2 4
5.755354 1.5 4 5.755354 1.5 4
9.5 1.5 4 9.5 1.5 4
4.146974 0.75 4 4.146974 0.75 4
5.644507 2 4 5.644507 2 4
3.5 1 4 3.5 1 4
2.5385938 0 4 2.5385938 0 4
3.0950184 -1 4 3.0950184 -1 4
3.4311862 -0.625 4 3.4311862 -0.625 4
4.323779 -1.25 4 4.323779 -1.25 4
3.0950184 -1 4 3.0950184 -1 4
5.9513144 -3 4 5.9513144 -3 4
6.108964 -2.5 4 6.108964 -2.5 4
4.323779 -1.25 4 4.323779 -1.25 4
5.9513144 -3 4 5.9513144 -3 4
9.5 -2.5 4 9.5 -2.5 4
6.108964 -2.5 4 6.108964 -2.5 4
9.5 -3 4 9.5 -3 4
6.108964 -2.5 4 6.108964 -2.5 4
5.9513144 -3 4 5.9513144 -3 4
9.5 -3 4 9.5 -3 4
2.5385938 0 4 2.5385938 0 4
0 -1 4 0 -1 4
3.0950184 -1 4 3.0950184 -1 4
0 -1 4 0 -1 4
2.5385938 0 4 2.5385938 0 4
0 0 4 0 0 4
3 0 1 2 3 0 1 2
3 3 4 5 3 3 4 5
3 6 7 8 3 6 7 8
3 9 10 11 3 9 10 11
3 12 13 14 3 12 13 14
3 15 16 17 3 15 16 17
3 18 19 20 3 18 19 20
3 21 22 23 3 21 22 23
3 24 25 26 3 24 25 26
3 27 28 29 3 27 28 29
3 30 31 32 3 30 31 32
3 33 34 35 3 33 34 35
3 36 37 38 3 36 37 38
3 39 40 41 3 39 40 41
3 42 43 44 3 42 43 44
3 45 46 47 3 45 46 47
3 48 49 50 3 48 49 50
3 51 52 53 3 51 52 53
3 54 55 56 3 54 55 56
3 57 58 59 3 57 58 59
3 60 61 62 3 60 61 62
3 63 64 65 3 63 64 65
3 66 67 68 3 66 67 68
3 69 70 71 3 69 70 71
3 72 73 74 3 72 73 74
3 75 76 77 3 75 76 77
3 78 79 80 3 78 79 80
3 81 82 83 3 81 82 83
3 84 85 86 3 84 85 86
3 87 88 89 3 87 88 89
3 90 91 92 3 90 91 92
3 93 94 95 3 93 94 95
3 96 97 98 3 96 97 98
3 99 100 101 3 99 100 101
3 102 103 104 3 102 103 104
3 105 106 107 3 105 106 107
3 108 109 110 3 108 109 110
3 111 112 113 3 111 112 113
3 114 115 116 3 114 115 116
3 117 118 119 3 117 118 119
3 120 121 122 3 120 121 122
3 123 124 125 3 123 124 125
3 126 127 128 3 126 127 128
3 129 130 131 3 129 130 131
3 132 133 134 3 132 133 134
3 135 136 137 3 135 136 137
3 138 139 140 3 138 139 140
3 141 142 143 3 141 142 143
3 144 145 146 3 144 145 146
3 147 148 149 3 147 148 149
3 150 151 152 3 150 151 152
3 153 154 155 3 153 154 155
3 156 157 158 3 156 157 158
3 159 160 161 3 159 160 161
3 162 163 164 3 162 163 164
3 165 166 167 3 165 166 167
3 168 169 170 3 168 169 170
3 171 172 173 3 171 172 173
3 174 175 176 3 174 175 176
3 177 178 179 3 177 178 179
3 180 181 182 3 180 181 182
3 183 184 185 3 183 184 185
3 186 187 188 3 186 187 188
3 189 190 191 3 189 190 191
3 192 193 194 3 192 193 194
3 195 196 197 3 195 196 197
3 198 199 200 3 198 199 200
3 201 202 203 3 201 202 203

View File

@ -4,6 +4,7 @@ import { EngineCommand } from '../../src/lang/std/engineConnection'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { getUtils } from './test-utils' import { getUtils } from './test-utils'
import waitOn from 'wait-on' import waitOn from 'wait-on'
import { Themes } from '../../src/lib/theme'
/* /*
debug helper: unfortunately we do rely on exact coord mouse clicks in a few places debug helper: unfortunately we do rely on exact coord mouse clicks in a few places
@ -631,3 +632,106 @@ test('Selections work on fresh and edited sketch', async ({ page }) => {
// hover again and check it works // hover again and check it works
await selectionSequence() await selectionSequence()
}) })
test('Command bar works and can change a setting', async ({ page }) => {
// Brief boilerplate
const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
let cmdSearchBar = page.getByPlaceholder('Search commands')
// First try opening the command bar and closing it
// It has a different label on mac and windows/linux, "Meta+K" and "Ctrl+/" respectively
await page
.getByRole('button', { name: 'Ctrl+/' })
.or(page.getByRole('button', { name: '⌘K' }))
.click()
await expect(cmdSearchBar).toBeVisible()
await page.keyboard.press('Escape')
await expect(cmdSearchBar).not.toBeVisible()
// Now try the same, but with the keyboard shortcut, check focus
await page.keyboard.press('Meta+K')
await expect(cmdSearchBar).toBeVisible()
await expect(cmdSearchBar).toBeFocused()
// Try typing in the command bar
await page.keyboard.type('theme')
const themeOption = page.getByRole('option', { name: 'Set Theme' })
await expect(themeOption).toBeVisible()
await themeOption.click()
const themeInput = page.getByPlaceholder('Select an option')
await expect(themeInput).toBeVisible()
await expect(themeInput).toBeFocused()
// Select dark theme
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await expect(page.getByRole('option', { name: Themes.Dark })).toHaveAttribute(
'data-headlessui-state',
'active'
)
await page.keyboard.press('Enter')
// Check the toast appeared
await expect(page.getByText(`Set Theme to "${Themes.Dark}"`)).toBeVisible()
// Check that the theme changed
await expect(page.locator('body')).toHaveClass(`body-bg ${Themes.Dark}`)
})
test('Can extrude from the command bar', async ({ page, context }) => {
await context.addInitScript(async (token) => {
localStorage.setItem(
'persistCode',
`const part001 = startSketchOn('-XZ')
|> startProfileAt([-6.95, 4.98], %)
|> line([25.1, 0.41], %)
|> line([0.73, -14.93], %)
|> line([-23.44, 0.52], %)
|> close(%)`
)
})
const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
let cmdSearchBar = page.getByPlaceholder('Search commands')
await page.keyboard.press('Meta+K')
await expect(cmdSearchBar).toBeVisible()
// Search for extrude command and choose it
await page.getByRole('option', { name: 'Extrude' }).click()
await expect(page.locator('#arg-form > label')).toContainText(
'Please select one face'
)
await expect(page.getByRole('button', { name: 'selection' })).toBeDisabled()
// Click to select face and set distance
await u.openAndClearDebugPanel()
await page.getByText('|> startProfileAt([-6.95, 4.98], %)').click()
await u.waitForCmdReceive('select_add')
await u.closeDebugPanel()
await page.getByRole('button', { name: 'Continue' }).click()
await expect(page.getByRole('button', { name: 'distance' })).toBeDisabled()
await page.keyboard.press('Enter')
// Review step and argument hotkeys
await page.keyboard.press('2')
await expect(page.getByRole('button', { name: '5' })).toBeDisabled()
await page.keyboard.press('Enter')
// Check that the code was updated
await page.keyboard.press('Enter')
await expect(page.locator('.cm-content')).toHaveText(
`const part001 = startSketchOn('-XZ')
|> startProfileAt([-6.95, 4.98], %)
|> line([25.1, 0.41], %)
|> line([0.73, -14.93], %)
|> line([-23.44, 0.52], %)
|> close(%)
|> extrude(5, %)`
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -29,13 +29,14 @@ describe('KCMA (Tauri, Linux)', () => {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }
const verifyUrl = `https://api.kittycad.io/oauth2/device/verify?user_code=${userCode}` const apiBaseUrl = process.env.VITE_KC_API_BASE_URL
const verifyUrl = `${apiBaseUrl}/oauth2/device/verify?user_code=${userCode}`
console.log(`GET ${verifyUrl}`) console.log(`GET ${verifyUrl}`)
const vr = await fetch(verifyUrl, { headers }) const vr = await fetch(verifyUrl, { headers })
console.log(vr.status) console.log(vr.status)
// Device flow: confirm // Device flow: confirm
const confirmUrl = 'https://api.kittycad.io/oauth2/device/confirm' const confirmUrl = `${apiBaseUrl}/oauth2/device/confirm`
const data = JSON.stringify({ user_code: userCode }) const data = JSON.stringify({ user_code: userCode })
console.log(`POST ${confirmUrl} ${data}`) console.log(`POST ${confirmUrl} ${data}`)
const cr = await fetch(confirmUrl, { const cr = await fetch(confirmUrl, {

View File

@ -60,6 +60,7 @@
}, },
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"start:prod": "vite preview --port=3000",
"serve": "vite serve --port=3000", "serve": "vite serve --port=3000",
"build": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && source \"$HOME/.cargo/env\" && curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -y && yarn build:wasm && vite build", "build": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && source \"$HOME/.cargo/env\" && curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -y && yarn build:wasm && vite build",
"build:local": "vite build", "build:local": "vite build",

View File

@ -1,3 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 3H4.5H11H11.2071L11.3536 3.14645L15.8536 7.64646L16 7.7929V8.00001V11.3773C15.6992 11.1362 15.3628 10.9376 15 10.7908V8.50001H11H10.5V8.00001V4H5V16H9.79076C9.93763 16.3628 10.1362 16.6992 10.3773 17H4.5H4V16.5V3.5V3ZM11.5 4.70711L14.2929 7.50001H11.5V4.70711ZM13 12V14H11V15H13V17H14V15H16V14H14V12H13Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 475 B

View File

@ -1,3 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 3.5H4H7H7.16667L7.3 3.6L9.16667 5H16H16.5V5.5V7.5V10.3773C16.1992 10.1362 15.8628 9.93763 15.5 9.79076V8H4.5V15.5H10.5351C10.7529 15.8764 11.0302 16.2141 11.3542 16.5H4H3.5V16V7.5V4V3.5ZM4.5 4.5V7H15.5V6H9H8.83333L8.7 5.9L6.83333 4.5H4.5ZM13.5 11V13H11.5V14H13.5V16H14.5V14H16.5V13H14.5V11H13.5Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 469 B

View File

@ -1,3 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 3.5H4.5V16.5H15.5V8.00001M11 3.5L15.5 8.00001M11 3.5V8.00001H15.5" stroke="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,7 +1,6 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/schema.json", "$schema": "../node_modules/@tauri-apps/cli/schema.json",
"build": { "build": {
"beforeBuildCommand": "yarn build:both",
"beforeDevCommand": "yarn start", "beforeDevCommand": "yarn start",
"devPath": "http://localhost:3000", "devPath": "http://localhost:3000",
"distDir": "../build" "distDir": "../build"

View File

@ -8,7 +8,7 @@ import {
createRoutesFromElements, createRoutesFromElements,
} from 'react-router-dom' } from 'react-router-dom'
import { GlobalStateProvider } from './components/GlobalStateProvider' import { GlobalStateProvider } from './components/GlobalStateProvider'
import CommandBarProvider from 'components/CommandBar' import CommandBarProvider from 'components/CommandBar/CommandBar'
import ModelingMachineProvider from 'components/ModelingMachineProvider' import ModelingMachineProvider from 'components/ModelingMachineProvider'
import { BROWSER_FILE_NAME } from 'Router' import { BROWSER_FILE_NAME } from 'Router'

View File

@ -172,11 +172,8 @@ export function App() {
<ModalContainer /> <ModalContainer />
<Resizable <Resizable
className={ className={
'h-full flex flex-col flex-1 z-10 my-5 ml-5 pr-1 transition-opacity transition-duration-75 ' + 'pointer-events-none h-full flex flex-col flex-1 z-10 my-5 ml-5 pr-1 transition-opacity transition-duration-75 ' +
(buttonDownInStream || onboardingStatus === 'camera' +paneOpacity
? ' pointer-events-none '
: ' ') +
paneOpacity
} }
defaultSize={{ defaultSize={{
width: '550px', width: '550px',
@ -188,10 +185,16 @@ export function App() {
maxHeight={'auto'} maxHeight={'auto'}
handleClasses={{ handleClasses={{
right: right:
'hover:bg-liquid-30/40 dark:hover:bg-liquid-10/40 bg-transparent transition-colors duration-100 transition-ease-out delay-100', 'hover:bg-chalkboard-10/50 bg-transparent transition-colors duration-75 transition-ease-out delay-100 ' +
(buttonDownInStream || onboardingStatus === 'camera'
? 'pointer-events-none '
: 'pointer-events-auto'),
}} }}
> >
<div id="code-pane" className="h-full flex flex-col justify-between"> <div
id="code-pane"
className="h-full flex flex-col justify-between pointer-events-none"
>
<CollapsiblePanel <CollapsiblePanel
title="Code" title="Code"
icon={faCode} icon={faCode}

View File

@ -38,7 +38,7 @@ import {
settingsMachine, settingsMachine,
} from './machines/settingsMachine' } from './machines/settingsMachine'
import { ContextFrom } from 'xstate' import { ContextFrom } from 'xstate'
import CommandBarProvider from 'components/CommandBar' import CommandBarProvider from 'components/CommandBar/CommandBar'
import { TEST, VITE_KC_SENTRY_DSN } from './env' import { TEST, VITE_KC_SENTRY_DSN } from './env'
import * as Sentry from '@sentry/react' import * as Sentry from '@sentry/react'
import ModelingMachineProvider from 'components/ModelingMachineProvider' import ModelingMachineProvider from 'components/ModelingMachineProvider'

View File

@ -1,106 +0,0 @@
.toolbarWrapper {
@apply relative;
}
.toolbar {
@apply flex gap-4 items-center rounded-full;
@apply border border-cool-20/30 bg-cool-10/50;
}
:global(.dark) .toolbar {
@apply border-cool-100/50 bg-cool-120/50;
}
:global(.sketch) .toolbar {
@apply border-fern-20/20 bg-fern-10/20;
}
:global(.dark .sketch) .toolbar {
@apply border-fern-120/50 bg-fern-100/30;
}
.toolbarCap {
@apply text-sm font-bold;
@apply bg-cool-20/50 text-cool-100;
}
:global(.dark) .toolbarCap {
@apply bg-cool-90/50 text-cool-30;
}
:global(.sketch) .toolbarCap {
@apply bg-fern-20/50 text-fern-100;
}
:global(.dark .sketch) .toolbarCap {
@apply bg-fern-90/50 text-fern-30;
}
.label {
@apply self-stretch flex items-center px-4 py-1;
@apply rounded-l-full;
}
.popoverToggle {
@apply self-stretch m-0 flex items-center px-4 py-1;
@apply rounded-r-full border-none;
@apply hover:bg-cool-20;
}
.toolbarButtons::-webkit-scrollbar {
@apply h-0.5;
}
.toolbarButtons {
@apply flex items-center overflow-x-auto;
scrollbar-width: thin;
}
.toolbarButtons button {
@apply text-chalkboard-90 bg-chalkboard-10/50 border-chalkboard-50 whitespace-nowrap;
display: inline-flex;
align-items: center;
justify-content: center;
@apply gap-1.5 p-0.5 pr-1;
@apply rounded-sm;
}
:global(.dark) .toolbarButtons button {
@apply text-chalkboard-30 bg-chalkboard-90/50 border-chalkboard-50;
}
.toolbarButtons button:hover {
@apply text-cool-90 bg-cool-10;
}
:global(.sketch) .toolbarButtons button:hover {
@apply text-fern-90 bg-fern-10;
}
.toolbarButtons button:disabled {
@apply text-chalkboard-70 bg-chalkboard-30;
}
.toolbarButtons button:disabled:hover {
@apply !bg-inherit !text-inherit cursor-not-allowed;
}
:global(.dark) .toolbarButtons button {
@apply text-chalkboard-20 border-chalkboard-50;
}
:global(.dark) .toolbarButtons button:hover {
@apply text-cool-10 border-chalkboard-50 bg-cool-90;
}
:global(.dark .sketch) .toolbarButtons button:hover {
@apply text-fern-10 border-chalkboard-50 bg-fern-90;
}
:global(.dark) .toolbarButtons button:disabled {
@apply text-chalkboard-40 bg-chalkboard-80;
}
:global(.dark) .popoverToggle {
@apply hover:bg-cool-90;
}
:global(.sketch) .popoverToggle {
@apply hover:bg-fern-20;
}
:global(.dark .sketch) .popoverToggle {
@apply hover:bg-fern-90;
}

View File

@ -1,22 +1,18 @@
import { Fragment, WheelEvent, useRef, useMemo } from 'react' import { WheelEvent, useRef, useMemo } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSearch, faX } from '@fortawesome/free-solid-svg-icons'
import { Popover, Transition } from '@headlessui/react'
import styles from './Toolbar.module.css'
import { isCursorInSketchCommandRange } from 'lang/util' import { isCursorInSketchCommandRange } from 'lang/util'
import { ActionIcon } from 'components/ActionIcon'
import { engineCommandManager } from './lang/std/engineConnection' import { engineCommandManager } from './lang/std/engineConnection'
import { useModelingContext } from 'hooks/useModelingContext' import { useModelingContext } from 'hooks/useModelingContext'
import { useCommandsContext } from 'hooks/useCommandsContext'
export const sketchButtonClassnames = { import { ActionButton } from 'components/ActionButton'
background: import usePlatform from 'hooks/usePlatform'
'bg-chalkboard-100 group-hover:bg-chalkboard-90 hover:bg-chalkboard-90 dark:bg-fern-20 dark:group-hover:bg-fern-10 dark:hover:bg-fern-10 group-disabled:bg-chalkboard-50 dark:group-disabled:bg-chalkboard-60 group-hover:group-disabled:bg-chalkboard-50 dark:group-hover:group-disabled:bg-chalkboard-50',
icon: 'text-fern-20 h-auto group-hover:text-fern-10 hover:text-fern-10 dark:text-chalkboard-100 dark:group-hover:text-chalkboard-100 dark:hover:text-chalkboard-100 group-disabled:bg-chalkboard-60 hover:group-disabled:text-inherit',
}
export const Toolbar = () => { export const Toolbar = () => {
const platform = usePlatform()
const { commandBarSend } = useCommandsContext()
const { state, send, context } = useModelingContext() const { state, send, context } = useModelingContext()
const toolbarButtonsRef = useRef<HTMLSpanElement>(null) const toolbarButtonsRef = useRef<HTMLUListElement>(null)
const bgClassName =
'group-enabled:group-hover:bg-energy-10 group-pressed:bg-energy-10 dark:group-enabled:group-hover:bg-chalkboard-80 dark:group-pressed:bg-chalkboard-80'
const pathId = useMemo( const pathId = useMemo(
() => () =>
isCursorInSketchCommandRange( isCursorInSketchCommandRange(
@ -35,72 +31,102 @@ export const Toolbar = () => {
span.scrollLeft = span.scrollLeft += ev.deltaY span.scrollLeft = span.scrollLeft += ev.deltaY
} }
function ToolbarButtons({ className }: React.HTMLAttributes<HTMLElement>) { function ToolbarButtons({
className = '',
...props
}: React.HTMLAttributes<HTMLElement>) {
return ( return (
<span <ul
{...props}
ref={toolbarButtonsRef} ref={toolbarButtonsRef}
onWheel={handleToolbarButtonsWheelEvent} onWheel={handleToolbarButtonsWheelEvent}
className={styles.toolbarButtons + ' ' + className} className={
'm-0 py-1 rounded-l-sm flex gap-2 items-center overflow-x-auto ' +
className
}
style={{ scrollbarWidth: 'thin' }}
> >
{state.nextEvents.includes('Enter sketch') && ( {state.nextEvents.includes('Enter sketch') && (
<button <li className="contents">
onClick={() => send({ type: 'Enter sketch' })} <ActionButton
className="group" Element="button"
> onClick={() => send({ type: 'Enter sketch' })}
<ActionIcon icon="sketch" className="!p-0.5" size="md" /> icon={{
<span data-testid="start-sketch">Start Sketch</span> icon: 'sketch',
</button> bgClassName,
}}
>
<span data-testid="start-sketch">Start Sketch</span>
</ActionButton>
</li>
)} )}
{state.nextEvents.includes('Enter sketch') && pathId && ( {state.nextEvents.includes('Enter sketch') && pathId && (
<button <li className="contents">
onClick={() => send({ type: 'Enter sketch' })} <ActionButton
className="group" Element="button"
> onClick={() => send({ type: 'Enter sketch' })}
<ActionIcon icon="sketch" className="!p-0.5" size="md" /> icon={{
Edit Sketch icon: 'sketch',
</button> bgClassName,
}}
>
Edit Sketch
</ActionButton>
</li>
)} )}
{state.nextEvents.includes('Cancel') && !state.matches('idle') && ( {state.nextEvents.includes('Cancel') && !state.matches('idle') && (
<button onClick={() => send({ type: 'Cancel' })} className="group"> <li className="contents">
<ActionIcon icon="exit" className="!p-0.5" size="md" /> <ActionButton
Exit Sketch Element="button"
</button> onClick={() => send({ type: 'Cancel' })}
icon={{
icon: 'arrowLeft',
bgClassName,
}}
>
Exit Sketch
</ActionButton>
</li>
)} )}
{state.matches('Sketch') && !state.matches('idle') && ( {state.matches('Sketch') && !state.matches('idle') && (
<button <li className="contents">
onClick={() => <ActionButton
state.matches('Sketch.Line Tool') Element="button"
? send('CancelSketch') onClick={() =>
: send('Equip tool') state.matches('Sketch.Line Tool')
} ? send('CancelSketch')
className={ : send('Equip tool')
'group ' + }
(state.matches('Sketch.Line Tool') aria-pressed={state.matches('Sketch.Line Tool')}
? '!text-fern-70 !bg-fern-10 !dark:text-fern-20 !border-fern-50' className="pressed:bg-energy-10/20 dark:pressed:bg-energy-80"
: '') icon={{
} icon: 'line',
> bgClassName,
<ActionIcon icon="line" className="!p-0.5" size="md" /> }}
Line >
</button> Line
</ActionButton>
</li>
)} )}
{state.matches('Sketch') && ( {state.matches('Sketch') && (
<button <li className="contents">
onClick={() => <ActionButton
state.matches('Sketch.Move Tool') Element="button"
? send('CancelSketch') onClick={() =>
: send('Equip move tool') state.matches('Sketch.Move Tool')
} ? send('CancelSketch')
className={ : send('Equip move tool')
'group ' + }
(state.matches('Sketch.Move Tool') aria-pressed={state.matches('Sketch.Move Tool')}
? '!text-fern-70 !bg-fern-10 !dark:text-fern-20 !border-fern-50' className="pressed:bg-energy-10/20 dark:pressed:bg-energy-80"
: '') icon={{
} icon: 'move',
> bgClassName,
<ActionIcon icon="move" className="!p-0.5" size="md" /> }}
Move >
</button> Move
</ActionButton>
</li>
)} )}
{state.matches('Sketch.SketchIdle') && {state.matches('Sketch.SketchIdle') &&
state.nextEvents state.nextEvents
@ -125,102 +151,71 @@ export const Toolbar = () => {
return 0 return 0
}) })
.map((eventName) => ( .map((eventName) => (
<button <li className="contents">
key={eventName} <ActionButton
onClick={() => send(eventName)} Element="button"
className="group" className="text-sm"
disabled={ key={eventName}
!state.nextEvents onClick={() => send(eventName)}
.filter((event) => state.can(event as any)) disabled={
.includes(eventName) !state.nextEvents
} .filter((event) => state.can(event as any))
title={eventName} .includes(eventName)
> }
<ActionIcon title={eventName}
icon={'line'} // TODO icon={{
bgClassName={sketchButtonClassnames.background} icon: 'line',
iconClassName={sketchButtonClassnames.icon} bgClassName,
size="md" }}
/> >
{eventName {eventName
.replace('Make segment ', '') .replace('Make segment ', '')
.replace('Constrain ', '')} .replace('Constrain ', '')}
</button> </ActionButton>
</li>
))} ))}
{state.matches('idle') && ( {state.matches('idle') && (
<button <li className="contents">
onClick={() => send('extrude intent')} <ActionButton
disabled={!state.can('extrude intent')} Element="button"
className="group" className="text-sm"
title={ onClick={() =>
state.can('extrude intent') commandBarSend({
? 'extrude' type: 'Find and select command',
: 'sketches need to be closed, or not already extruded' data: { name: 'Extrude', ownerMachine: 'modeling' },
} })
> }
<ActionIcon icon="extrude" className="!p-0.5" size="md" /> disabled={!state.can('Extrude')}
Extrude title={
</button> state.can('Extrude')
? 'extrude'
: 'sketches need to be closed, or not already extruded'
}
icon={{
icon: 'extrude',
bgClassName,
}}
>
Extrude
</ActionButton>
</li>
)} )}
</span> </ul>
) )
} }
return ( return (
<Popover <div className="max-w-full flex items-stretch rounded-l-sm rounded-r-full bg-chalkboard-10 dark:bg-chalkboard-100 relative">
className={ <menu className="flex-1 pl-1 pr-2 py-0 overflow-hidden rounded-l-sm whitespace-nowrap bg-chalkboard-10 dark:bg-chalkboard-100 border-solid border border-energy-10 dark:border-chalkboard-90 border-r-0">
styles.toolbarWrapper + state.matches('Sketch') ? ' sketch' : '' <ToolbarButtons />
} </menu>
> <ActionButton
<div className={styles.toolbar}> Element="button"
<span className={styles.toolbarCap + ' ' + styles.label}> onClick={() => commandBarSend({ type: 'Open' })}
{state.matches('Sketch') ? '2D' : '3D'} className="rounded-r-full pr-4 self-stretch border-energy-10 hover:border-energy-10 dark:border-chalkboard-80 bg-energy-10/50 hover:bg-energy-10 dark:bg-chalkboard-80 dark:text-energy-10"
</span>
<menu className="flex-1 gap-2 py-0.5 overflow-hidden whitespace-nowrap">
<ToolbarButtons />
</menu>
<Popover.Button
className={styles.toolbarCap + ' ' + styles.popoverToggle}
>
<FontAwesomeIcon icon={faSearch} />
</Popover.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-out duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
> >
<Popover.Overlay className="fixed inset-0 bg-chalkboard-110/20 dark:bg-chalkboard-110/50" /> {platform === 'darwin' ? '⌘K' : 'Ctrl+/'}
</Transition> </ActionButton>
<Transition </div>
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 translate-y-1 scale-95"
enterTo="opacity-100 translate-y-0 scale-100"
leave="transition ease-out duration-75"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-2"
>
<Popover.Panel className="absolute top-0 w-screen max-w-xl left-1/2 -translate-x-1/2 flex flex-col gap-8 bg-chalkboard-10 dark:bg-chalkboard-100 p-5 rounded border border-chalkboard-20/30 dark:border-chalkboard-70/50">
<section className="flex justify-between items-center">
<p
className={`${styles.toolbarCap} ${styles.label} !self-center rounded-r-full w-fit`}
>
You're in {state.matches('Sketch') ? '2D' : '3D'}
</p>
<Popover.Button className="p-2 flex items-center justify-center rounded-sm bg-chalkboard-20 text-chalkboard-110 dark:bg-chalkboard-70 dark:text-chalkboard-20 border-none hover:bg-chalkboard-30 dark:hover:bg-chalkboard-60">
<FontAwesomeIcon icon={faX} className="w-4 h-4" />
</Popover.Button>
</section>
<section>
<ToolbarButtons className="flex-wrap" />
</section>
</Popover.Panel>
</Transition>
</Popover>
) )
} }

View File

@ -39,16 +39,16 @@ type ActionButtonProps =
| ActionButtonAsElement | ActionButtonAsElement
export const ActionButton = (props: ActionButtonProps) => { export const ActionButton = (props: ActionButtonProps) => {
const classNames = `group mono text-base flex items-center gap-2 rounded-sm border border-chalkboard-40 dark:border-chalkboard-60 hover:border-liquid-40 dark:hover:bg-chalkboard-90 p-[3px] text-chalkboard-110 dark:text-chalkboard-10 hover:text-chalkboard-110 hover:dark:text-chalkboard-10 ${ const classNames = `action-button m-0 group mono text-sm flex items-center gap-2 rounded-sm border-solid border border-chalkboard-30 hover:border-chalkboard-40 dark:border-chalkboard-70 dark:hover:border-chalkboard-60 dark:bg-chalkboard-90/50 p-[3px] text-chalkboard-100 dark:text-chalkboard-10 ${
props.icon ? 'pr-2' : 'px-2' props.icon ? 'pr-2' : 'px-2'
} ${props.className || ''}` } ${props.className ? props.className : ''}`
switch (props.Element) { switch (props.Element) {
case 'button': { case 'button': {
// Note we have to destructure 'className' and 'Element' out of props // Note we have to destructure 'className' and 'Element' out of props
// because we don't want to pass them to the button element; // because we don't want to pass them to the button element;
// the same is true for the other cases below. // the same is true for the other cases below.
const { Element, icon, children, className, ...rest } = props const { Element, icon, children, className: _className, ...rest } = props
return ( return (
<button className={classNames} {...rest}> <button className={classNames} {...rest}>
{props.icon && <ActionIcon {...icon} />} {props.icon && <ActionIcon {...icon} />}
@ -57,7 +57,14 @@ export const ActionButton = (props: ActionButtonProps) => {
) )
} }
case 'link': { case 'link': {
const { Element, to, icon, children, className, ...rest } = props const {
Element,
to,
icon,
children,
className: _className,
...rest
} = props
return ( return (
<Link to={to || paths.INDEX} className={classNames} {...rest}> <Link to={to || paths.INDEX} className={classNames} {...rest}>
{icon && <ActionIcon {...icon} />} {icon && <ActionIcon {...icon} />}
@ -66,7 +73,14 @@ export const ActionButton = (props: ActionButtonProps) => {
) )
} }
case 'externalLink': { case 'externalLink': {
const { Element, to, icon, children, className, ...rest } = props const {
Element,
to,
icon,
children,
className: _className,
...rest
} = props
return ( return (
<Link <Link
to={to || paths.INDEX} to={to || paths.INDEX}
@ -80,7 +94,7 @@ export const ActionButton = (props: ActionButtonProps) => {
) )
} }
default: { default: {
const { Element, icon, children, className, ...rest } = props const { Element, icon, children, className: _className, ...rest } = props
if (!Element) throw new Error('Element is required') if (!Element) throw new Error('Element is required')
return ( return (

View File

@ -7,10 +7,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { CustomIcon, CustomIconName } from './CustomIcon' import { CustomIcon, CustomIconName } from './CustomIcon'
const iconSizes = { const iconSizes = {
sm: 12, xs: 12,
md: 14.4, sm: 14,
lg: 20, md: 20,
xl: 28, lg: 24,
} }
export interface ActionIconProps extends React.PropsWithChildren { export interface ActionIconProps extends React.PropsWithChildren {
@ -30,20 +30,14 @@ export const ActionIcon = ({
children, children,
}: ActionIconProps) => { }: ActionIconProps) => {
// By default, we reverse the icon color and background color in dark mode // By default, we reverse the icon color and background color in dark mode
const computedIconClassName = const computedIconClassName = `h-auto dark:text-energy-10 !group-disabled:text-chalkboard-60 !group-disabled:text-chalkboard-60 ${iconClassName}`
iconClassName ||
`text-liquid-20 h-auto group-hover:text-liquid-10 hover:text-liquid-10 dark:text-chalkboard-100 dark:group-hover:text-chalkboard-100 dark:hover:text-chalkboard-100 group-disabled:bg-chalkboard-50 dark:group-disabled:bg-chalkboard-60 group-hover:group-disabled:bg-chalkboard-50 dark:group-hover:group-disabled:bg-chalkboard-50`
const computedBgClassName = const computedBgClassName = `bg-chalkboard-20 dark:bg-chalkboard-90 !group-disabled:bg-chalkboard-30 !dark:group-disabled:bg-chalkboard-80 ${bgClassName}`
bgClassName ||
`bg-chalkboard-100 group-hover:bg-chalkboard-90 hover:bg-chalkboard-90 dark:bg-liquid-20 dark:group-hover:bg-liquid-10 dark:hover:bg-liquid-10 group-disabled:bg-chalkboard-80 dark:group-disabled:bg-chalkboard-80`
return ( return (
<div <div
className={ className={
`p-${ `w-fit inline-grid place-content-center ${className} ` +
size === 'xl' ? '2' : '1'
} w-fit inline-grid place-content-center ${className} ` +
computedBgClassName computedBgClassName
} }
> >

View File

@ -5,6 +5,9 @@ import ProjectSidebarMenu from './ProjectSidebarMenu'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext' import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import styles from './AppHeader.module.css' import styles from './AppHeader.module.css'
import { NetworkHealthIndicator } from './NetworkHealthIndicator' import { NetworkHealthIndicator } from './NetworkHealthIndicator'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { ActionButton } from './ActionButton'
import usePlatform from 'hooks/usePlatform'
interface AppHeaderProps extends React.PropsWithChildren { interface AppHeaderProps extends React.PropsWithChildren {
showToolbar?: boolean showToolbar?: boolean
@ -20,13 +23,15 @@ export const AppHeader = ({
className = '', className = '',
enableMenu = false, enableMenu = false,
}: AppHeaderProps) => { }: AppHeaderProps) => {
const platform = usePlatform()
const { commandBarSend } = useCommandsContext()
const { auth } = useGlobalStateContext() const { auth } = useGlobalStateContext()
const user = auth?.context?.user const user = auth?.context?.user
return ( return (
<header <header
className={ className={
(showToolbar ? 'w-full grid ' : 'flex justify-between ') + 'w-full grid ' +
styles.header + styles.header +
' overlaid-panes sticky top-0 z-20 py-1 px-5 bg-chalkboard-10/70 dark:bg-chalkboard-100/50 border-b dark:border-b-2 border-chalkboard-30 dark:border-chalkboard-90 items-center ' + ' overlaid-panes sticky top-0 z-20 py-1 px-5 bg-chalkboard-10/70 dark:bg-chalkboard-100/50 border-b dark:border-b-2 border-chalkboard-30 dark:border-chalkboard-90 items-center ' +
className className
@ -38,18 +43,31 @@ export const AppHeader = ({
file={project?.file} file={project?.file}
/> />
{/* Toolbar if the context deems it */} {/* Toolbar if the context deems it */}
{showToolbar && ( <div className="flex-grow flex justify-center max-w-lg md:max-w-xl lg:max-w-2xl xl:max-w-4xl 2xl:max-w-5xl">
<div className="max-w-lg md:max-w-xl lg:max-w-2xl xl:max-w-4xl 2xl:max-w-5xl"> {showToolbar ? (
<Toolbar /> <Toolbar />
</div> ) : (
)} <ActionButton
{/* If there are children, show them, otherwise show User menu */} Element="button"
{children || ( onClick={() => commandBarSend({ type: 'Open' })}
<div className="flex items-center gap-1 ml-auto"> className="text-sm self-center flex items-center w-fit gap-3"
<NetworkHealthIndicator /> >
<UserSidebarMenu user={user} /> Command Palette{' '}
</div> <kbd className="bg-energy-10/50 dark:bg-chalkboard-100 dark:text-energy-10 inline-block px-1 py-0.5 border-energy-10 dark:border-chalkboard-90">
)} {platform === 'darwin' ? '⌘K' : 'Ctrl+/'}
</kbd>
</ActionButton>
)}
</div>
<div className="flex items-center gap-1 ml-auto">
{/* If there are children, show them, otherwise show User menu */}
{children || (
<>
<NetworkHealthIndicator />
<UserSidebarMenu user={user} />
</>
)}
</div>
</header> </header>
) )
} }

View File

@ -1,13 +1,13 @@
.button { .button {
@apply flex justify-between items-center gap-2 px-2 py-1 text-left border-none rounded-sm; @apply flex justify-between items-center gap-2 px-2 py-1 text-left border-none rounded-sm;
@apply font-mono text-xs font-bold select-none text-chalkboard-90; @apply font-mono text-xs font-bold select-none text-chalkboard-90;
@apply ui-active:bg-liquid-10/50 ui-active:text-liquid-90; @apply ui-active:bg-energy-10/50 ui-active:text-inherit;
@apply transition-colors ease-out; @apply transition-colors ease-out;
} }
:global(.dark) .button { :global(.dark) .button {
@apply text-chalkboard-30; @apply text-chalkboard-30;
@apply ui-active:bg-chalkboard-80 ui-active:text-liquid-10; @apply ui-active:bg-chalkboard-80 ui-active:text-energy-10;
} }
.button small { .button small {

View File

@ -30,8 +30,10 @@ export const CodeMenu = ({ children }: PropsWithChildren) => {
<Menu.Button className="p-0 border-none relative"> <Menu.Button className="p-0 border-none relative">
<ActionIcon <ActionIcon
icon={faEllipsis} icon={faEllipsis}
className="p-1"
size="sm"
bgClassName={ bgClassName={
'bg-chalkboard-20 dark:bg-chalkboard-110 hover:bg-liquid-10/50 hover:dark:bg-chalkboard-90 ui-active:bg-chalkboard-80 ui-active:dark:bg-chalkboard-90 rounded' 'bg-chalkboard-20 dark:bg-chalkboard-110 hover:bg-energy-10/50 hover:dark:bg-chalkboard-90 ui-active:bg-chalkboard-80 ui-active:dark:bg-chalkboard-90 rounded-sm'
} }
iconClassName={'text-chalkboard-90 dark:text-chalkboard-40'} iconClassName={'text-chalkboard-90 dark:text-chalkboard-40'}
/> />

View File

@ -24,16 +24,17 @@ export const PanelHeader = ({
}: CollapsiblePanelProps) => { }: CollapsiblePanelProps) => {
return ( return (
<summary className={styles.header}> <summary className={styles.header}>
<div className="flex gap-2 align-center flex-1"> <div className="flex gap-2 items-center flex-1">
<ActionIcon <ActionIcon
icon={icon} icon={icon}
className="p-1"
size="sm"
bgClassName={ bgClassName={
'bg-chalkboard-30 dark:bg-chalkboard-90 group-open:bg-chalkboard-80 rounded ' + 'dark:!bg-chalkboard-100 group-open:bg-chalkboard-80 dark:group-open:!bg-chalkboard-90 border border-transparent dark:group-open:border-chalkboard-60 rounded-sm ' +
(iconClassNames?.bg || '') (iconClassNames?.bg || '')
} }
iconClassName={ iconClassName={
'text-chalkboard-90 dark:text-chalkboard-40 group-open:text-liquid-10 ' + 'group-open:text-energy-10 ' + (iconClassNames?.icon || '')
(iconClassNames?.icon || '')
} }
/> />
{title} {title}
@ -59,7 +60,9 @@ export const CollapsiblePanel = ({
<details <details
{...props} {...props}
data-testid={detailsTestId} data-testid={detailsTestId}
className={styles.panel + ' group ' + (className || '')} className={
styles.panel + ' pointer-events-auto group ' + (className || '')
}
> >
<PanelHeader <PanelHeader
title={title} title={title}

View File

@ -1,290 +0,0 @@
import { Combobox, Dialog, Transition } from '@headlessui/react'
import {
Dispatch,
Fragment,
SetStateAction,
createContext,
useState,
} from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { ActionIcon } from './ActionIcon'
import { faSearch } from '@fortawesome/free-solid-svg-icons'
import Fuse from 'fuse.js'
import { Command, SubCommand } from '../lib/commands'
import { useCommandsContext } from 'hooks/useCommandsContext'
export type SortedCommand = {
item: Partial<Command | SubCommand> & { name: string }
}
export const CommandsContext = createContext(
{} as {
commands: Command[]
addCommands: (commands: Command[]) => void
removeCommands: (commands: Command[]) => void
commandBarOpen: boolean
setCommandBarOpen: Dispatch<SetStateAction<boolean>>
}
)
export const CommandBarProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const [commands, internalSetCommands] = useState([] as Command[])
const [commandBarOpen, setCommandBarOpen] = useState(false)
const addCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) => [...newCommands, ...prevCommands])
}
const removeCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) =>
prevCommands.filter((command) => !newCommands.includes(command))
)
}
return (
<CommandsContext.Provider
value={{
commands,
addCommands,
removeCommands,
commandBarOpen,
setCommandBarOpen,
}}
>
{children}
<CommandBar />
</CommandsContext.Provider>
)
}
const CommandBar = () => {
const { commands, commandBarOpen, setCommandBarOpen } = useCommandsContext()
useHotkeys(['meta+k', 'meta+/'], () => {
if (commands.length === 0) return
setCommandBarOpen(!commandBarOpen)
})
const [selectedCommand, setSelectedCommand] = useState<SortedCommand | null>(
null
)
// keep track of the current subcommand index
const [subCommandIndex, setSubCommandIndex] = useState<number>()
const [subCommandData, setSubCommandData] = useState<{
[key: string]: string
}>({})
// if the subcommand index is null, we're not in a subcommand
const inSubCommand =
selectedCommand &&
'meta' in selectedCommand.item &&
selectedCommand.item.meta?.args !== undefined &&
subCommandIndex !== undefined
const currentSubCommand =
inSubCommand && 'meta' in selectedCommand.item
? selectedCommand.item.meta?.args[subCommandIndex]
: undefined
const [query, setQuery] = useState('')
const availableCommands =
inSubCommand && currentSubCommand
? currentSubCommand.type === 'string'
? query
? [{ name: query }]
: currentSubCommand.options
: currentSubCommand.options
: commands
const fuse = new Fuse(availableCommands || [], {
keys: ['name', 'description'],
})
const filteredCommands = query
? fuse.search(query)
: availableCommands?.map((c) => ({ item: c } as SortedCommand))
function clearState() {
setQuery('')
setCommandBarOpen(false)
setSelectedCommand(null)
setSubCommandIndex(undefined)
setSubCommandData({})
}
function handleCommandSelection(entry: SortedCommand) {
// If we have subcommands and have not yet gathered all the
// data required from them, set the selected command to the
// current command and increment the subcommand index
if (selectedCommand === null && 'meta' in entry.item && entry.item.meta) {
setSelectedCommand(entry)
setSubCommandIndex(0)
setQuery('')
return
}
const { item } = entry
// If we have just selected a command with no subcommands, run it
const isCommandWithoutSubcommands =
'callback' in item && !('meta' in item && item.meta)
if (isCommandWithoutSubcommands) {
if (item.callback === undefined) return
item.callback()
setCommandBarOpen(false)
return
}
// If we have subcommands and have not yet gathered all the
// data required from them, set the selected command to the
// current command and increment the subcommand index
if (
selectedCommand &&
subCommandIndex !== undefined &&
'meta' in selectedCommand.item
) {
const subCommand = selectedCommand.item.meta?.args[subCommandIndex]
if (subCommand) {
const newSubCommandData = {
...subCommandData,
[subCommand.name]: item.name,
}
const newSubCommandIndex = subCommandIndex + 1
// If we have subcommands and have gathered all the data required
// from them, run the command with the gathered data
if (
selectedCommand.item.callback &&
selectedCommand.item.meta?.args.length === newSubCommandIndex
) {
selectedCommand.item.callback(newSubCommandData)
setCommandBarOpen(false)
} else {
// Otherwise, set the subcommand data and increment the subcommand index
setSubCommandData(newSubCommandData)
setSubCommandIndex(newSubCommandIndex)
setQuery('')
}
}
}
}
function getDisplayValue(command: Command) {
if (command.meta?.displayValue === undefined || !command.meta.args)
return command.name
return command.meta?.displayValue(
command.meta.args.map((c) =>
subCommandData[c.name] ? subCommandData[c.name] : `<${c.name}>`
)
)
}
return (
<Transition.Root
show={
commandBarOpen &&
availableCommands?.length !== undefined &&
availableCommands.length > 0
}
as={Fragment}
afterLeave={() => clearState()}
>
<Dialog
onClose={() => {
setCommandBarOpen(false)
clearState()
}}
className="fixed inset-0 z-40 overflow-y-auto p-4 pt-[25vh]"
>
<Transition.Child
enter="duration-100 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="duration-75 ease-in"
leaveFrom="opacity-100"
leaveTo="opacity-0"
as={Fragment}
>
<Dialog.Overlay className="fixed inset-0 bg-chalkboard-10/70 dark:bg-chalkboard-110/50" />
</Transition.Child>
<Transition.Child
enter="duration-100 ease-out"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-75 ease-in"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
as={Fragment}
>
<Combobox
value={selectedCommand}
onChange={handleCommandSelection}
className="relative w-full max-w-xl p-2 mx-auto border rounded shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
as="div"
>
<div className="flex items-center gap-2">
<ActionIcon icon={faSearch} size="xl" className="rounded-sm" />
<div>
{inSubCommand && (
<p className="text-liquid-70 dark:text-liquid-30">
{selectedCommand.item &&
getDisplayValue(selectedCommand.item as Command)}
</p>
)}
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
className="w-full bg-transparent focus:outline-none"
onKeyDown={(event) => {
if (event.metaKey && event.key === 'k')
setCommandBarOpen(false)
if (
inSubCommand &&
event.key === 'Backspace' &&
!event.currentTarget.value
) {
setSubCommandIndex(subCommandIndex - 1)
setSelectedCommand(null)
}
}}
displayValue={(command: SortedCommand) =>
command !== null ? command.item.name : ''
}
placeholder={
inSubCommand
? `Enter <${currentSubCommand?.name}>`
: 'Search for a command'
}
value={query}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/>
</div>
</div>
<Combobox.Options static className="overflow-y-auto max-h-96">
{filteredCommands?.map((commandResult) => (
<Combobox.Option
key={commandResult.item.name}
value={commandResult}
className="px-2 py-1 my-2 first:mt-4 last:mb-4 ui-active:bg-liquid-10 dark:ui-active:bg-liquid-90"
>
<p>{commandResult.item.name}</p>
{(commandResult.item as SubCommand).description && (
<p className="mt-0.5 text-liquid-70 dark:text-liquid-30 text-sm">
{(commandResult.item as SubCommand).description}
</p>
)}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
</Transition.Child>
</Dialog>
</Transition.Root>
)
}
export default CommandBarProvider

View File

@ -0,0 +1,114 @@
import { Combobox } from '@headlessui/react'
import Fuse from 'fuse.js'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { CommandArgumentOption } from 'lib/commandTypes'
import { useEffect, useRef, useState } from 'react'
function CommandArgOptionInput({
options,
argName,
stepBack,
onSubmit,
placeholder,
}: {
options: CommandArgumentOption<unknown>[]
argName: string
stepBack: () => void
onSubmit: (data: unknown) => void
placeholder?: string
}) {
const { commandBarSend, commandBarState } = useCommandsContext()
const inputRef = useRef<HTMLInputElement>(null)
const formRef = useRef<HTMLFormElement>(null)
const [argValue, setArgValue] = useState<(typeof options)[number]['value']>(
options.find((o) => 'isCurrent' in o && o.isCurrent)?.value ||
commandBarState.context.argumentsToSubmit[argName] ||
options[0].value
)
const [query, setQuery] = useState('')
const [filteredOptions, setFilteredOptions] = useState<typeof options>()
const fuse = new Fuse(options, {
keys: ['name', 'description'],
threshold: 0.3,
})
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, [inputRef])
useEffect(() => {
const results = fuse.search(query).map((result) => result.item)
setFilteredOptions(query.length > 0 ? results : options)
}, [query])
function handleSelectOption(option: CommandArgumentOption<unknown>) {
setArgValue(option)
onSubmit(option.value)
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
onSubmit(argValue)
}
return (
<form id="arg-form" onSubmit={handleSubmit} ref={formRef}>
<Combobox value={argValue} onChange={handleSelectOption} name="options">
<div className="flex items-center mx-4 mt-4 mb-2">
<label
htmlFor="option-input"
className="capitalize px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80"
>
{argName}
</label>
<Combobox.Input
id="option-input"
ref={inputRef}
onChange={(event) => setQuery(event.target.value)}
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
onKeyDown={(event) => {
if (event.metaKey && event.key === 'k')
commandBarSend({ type: 'Close' })
if (event.key === 'Backspace' && !event.currentTarget.value) {
stepBack()
}
}}
placeholder={
(argValue as CommandArgumentOption<unknown>)?.name ||
placeholder ||
'Select an option for ' + argName
}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
autoFocus
/>
</div>
<Combobox.Options
static
className="overflow-y-auto max-h-96 cursor-pointer"
>
{filteredOptions?.map((option) => (
<Combobox.Option
key={option.name}
value={option}
className="flex items-center gap-2 px-4 py-1 first:mt-2 last:mb-2 ui-active:bg-energy-10/50 dark:ui-active:bg-chalkboard-90"
>
<p className="flex-grow">{option.name} </p>
{'isCurrent' in option && option.isCurrent && (
<small className="text-chalkboard-70 dark:text-chalkboard-50">
current
</small>
)}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
</form>
)
}
export default CommandArgOptionInput

View File

@ -0,0 +1,166 @@
import { Dialog, Popover, Transition } from '@headlessui/react'
import { Fragment, createContext, useEffect } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { useMachine } from '@xstate/react'
import { commandBarMachine } from 'machines/commandBarMachine'
import { EventFrom, StateFrom } from 'xstate'
import CommandBarArgument from './CommandBarArgument'
import CommandComboBox from '../CommandComboBox'
import { useLocation } from 'react-router-dom'
import CommandBarReview from './CommandBarReview'
type CommandsContextType = {
commandBarState: StateFrom<typeof commandBarMachine>
commandBarSend: (event: EventFrom<typeof commandBarMachine>) => void
}
export const CommandsContext = createContext<CommandsContextType>({
commandBarState: commandBarMachine.initialState,
commandBarSend: () => {},
})
export const CommandBarProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const { pathname } = useLocation()
const [commandBarState, commandBarSend] = useMachine(commandBarMachine, {
guards: {
'Arguments are ready': (context, _) => {
return context.selectedCommand?.args
? context.argumentsToSubmit.length ===
Object.keys(context.selectedCommand.args)?.length
: false
},
'Command has no arguments': (context, _event) => {
return (
!context.selectedCommand?.args ||
Object.keys(context.selectedCommand?.args).length === 0
)
},
},
})
// Close the command bar when navigating
useEffect(() => {
commandBarSend({ type: 'Close' })
}, [pathname])
return (
<CommandsContext.Provider
value={{
commandBarState,
commandBarSend,
}}
>
{children}
<CommandBar />
</CommandsContext.Provider>
)
}
const CommandBar = () => {
const { commandBarState, commandBarSend } = useCommandsContext()
const {
context: { selectedCommand, currentArgument, commands },
} = commandBarState
const isSelectionArgument = currentArgument?.inputType === 'selection'
const WrapperComponent = isSelectionArgument ? Popover : Dialog
useHotkeys(['mod+k', 'mod+/'], () => {
if (commandBarState.context.commands.length === 0) return
if (commandBarState.matches('Closed')) {
commandBarSend({ type: 'Open' })
} else {
commandBarSend({ type: 'Close' })
}
})
function stepBack() {
if (!currentArgument) {
if (commandBarState.matches('Review')) {
const entries = Object.entries(selectedCommand?.args || {})
commandBarSend({
type: commandBarState.matches('Review')
? 'Edit argument'
: 'Change current argument',
data: {
arg: {
name: entries[entries.length - 1][0],
...entries[entries.length - 1][1],
},
},
})
} else {
commandBarSend({ type: 'Deselect command' })
}
} else {
const entries = Object.entries(selectedCommand?.args || {})
const index = entries.findIndex(
([key, _]) => key === currentArgument.name
)
if (index === 0) {
commandBarSend({ type: 'Deselect command' })
} else {
commandBarSend({
type: 'Change current argument',
data: {
arg: { name: entries[index - 1][0], ...entries[index - 1][1] },
},
})
}
}
}
return (
<Transition.Root
show={!commandBarState.matches('Closed') || false}
afterLeave={() => {
if (selectedCommand?.onCancel) selectedCommand.onCancel()
commandBarSend({ type: 'Clear' })
}}
as={Fragment}
>
<WrapperComponent
open={!commandBarState.matches('Closed') || isSelectionArgument}
onClose={() => {
commandBarSend({ type: 'Close' })
}}
className={
'fixed inset-0 z-50 overflow-y-auto pb-4 pt-1 ' +
(isSelectionArgument ? 'pointer-events-none' : '')
}
>
<Transition.Child
enter="duration-100 ease-out"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-75 ease-in"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<WrapperComponent.Panel
className="relative z-50 pointer-events-auto w-full max-w-xl py-2 mx-auto border rounded shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
as="div"
>
{commandBarState.matches('Selecting command') ? (
<CommandComboBox options={commands} />
) : commandBarState.matches('Gathering arguments') ? (
<CommandBarArgument stepBack={stepBack} />
) : (
commandBarState.matches('Review') && (
<CommandBarReview stepBack={stepBack} />
)
)}
</WrapperComponent.Panel>
</Transition.Child>
</WrapperComponent>
</Transition.Root>
)
}
export default CommandBarProvider

View File

@ -0,0 +1,80 @@
import CommandArgOptionInput from './CommandArgOptionInput'
import CommandBarBasicInput from './CommandBarBasicInput'
import CommandBarSelectionInput from './CommandBarSelectionInput'
import { CommandArgument } from 'lib/commandTypes'
import { useCommandsContext } from 'hooks/useCommandsContext'
import CommandBarHeader from './CommandBarHeader'
function CommandBarArgument({ stepBack }: { stepBack: () => void }) {
const { commandBarState, commandBarSend } = useCommandsContext()
const {
context: { currentArgument },
} = commandBarState
function onSubmit(data: unknown) {
if (!currentArgument) return
commandBarSend({
type: 'Submit argument',
data: {
[currentArgument.name]:
currentArgument.inputType === 'number'
? parseFloat((data as string) || '0')
: data,
},
})
}
return (
currentArgument && (
<CommandBarHeader>
<ArgumentInput
arg={currentArgument}
stepBack={stepBack}
onSubmit={onSubmit}
/>
</CommandBarHeader>
)
)
}
export default CommandBarArgument
function ArgumentInput({
arg,
stepBack,
onSubmit,
}: {
arg: CommandArgument<unknown> & { name: string }
stepBack: () => void
onSubmit: (event: any) => void
}) {
switch (arg.inputType) {
case 'options':
return (
<CommandArgOptionInput
options={arg.options}
argName={arg.name}
stepBack={stepBack}
onSubmit={onSubmit}
placeholder="Select an option"
/>
)
case 'selection':
return (
<CommandBarSelectionInput
arg={arg}
stepBack={stepBack}
onSubmit={onSubmit}
/>
)
default:
return (
<CommandBarBasicInput
arg={arg}
stepBack={stepBack}
onSubmit={onSubmit}
/>
)
}
}

View File

@ -0,0 +1,66 @@
import { useCommandsContext } from 'hooks/useCommandsContext'
import { CommandArgument } from 'lib/commandTypes'
import { useEffect, useRef } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
function CommandBarBasicInput({
arg,
stepBack,
onSubmit,
}: {
arg: CommandArgument<unknown> & {
inputType: 'number' | 'string'
name: string
}
stepBack: () => void
onSubmit: (event: unknown) => void
}) {
const { commandBarSend, commandBarState } = useCommandsContext()
useHotkeys('mod + k, mod + /', () => commandBarSend({ type: 'Close' }))
const inputRef = useRef<HTMLInputElement>(null)
const inputType = arg.inputType === 'number' ? 'number' : 'text'
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [arg, inputRef])
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
onSubmit(inputRef.current?.value)
}
return (
<form id="arg-form" onSubmit={handleSubmit}>
<label className="flex items-center mx-4 my-4">
<span className="capitalize px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80">
{arg.name}
</span>
<input
id="arg-form"
name={inputType}
ref={inputRef}
type={inputType}
required
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
placeholder="Enter a value"
defaultValue={
(commandBarState.context.argumentsToSubmit[arg.name] as
| string
| undefined) || (arg.defaultValue as string)
}
onKeyDown={(event) => {
if (event.key === 'Backspace' && !event.currentTarget.value) {
stepBack()
}
}}
autoFocus
/>
</label>
</form>
)
}
export default CommandBarBasicInput

View File

@ -0,0 +1,194 @@
import { useCommandsContext } from 'hooks/useCommandsContext'
import { CustomIcon } from '../CustomIcon'
import React, { useState } from 'react'
import { ActionButton } from '../ActionButton'
import { Selections, getSelectionTypeDisplayText } from 'lib/selections'
import { useHotkeys } from 'react-hotkeys-hook'
function CommandBarHeader({ children }: React.PropsWithChildren<{}>) {
const { commandBarState, commandBarSend } = useCommandsContext()
const {
context: { selectedCommand, currentArgument, argumentsToSubmit },
} = commandBarState
const isReviewing = commandBarState.matches('Review')
const [showShortcuts, setShowShortcuts] = useState(false)
useHotkeys(
'alt',
() => setShowShortcuts(true),
{ enableOnFormTags: true, enableOnContentEditable: true },
[showShortcuts]
)
useHotkeys(
'alt',
() => setShowShortcuts(false),
{ keyup: true, enableOnFormTags: true, enableOnContentEditable: true },
[showShortcuts]
)
useHotkeys(
[
'alt+1',
'alt+2',
'alt+3',
'alt+4',
'alt+5',
'alt+6',
'alt+7',
'alt+8',
'alt+9',
'alt+0',
],
(_, b) => {
if (b.keys && !Number.isNaN(parseInt(b.keys[0], 10))) {
if (!selectedCommand?.args) return
const argName = Object.keys(selectedCommand.args)[
parseInt(b.keys[0], 10) - 1
]
const arg = selectedCommand?.args[argName]
commandBarSend({
type: 'Change current argument',
data: { arg: { ...arg, name: argName } },
})
}
},
{ keyup: true, enableOnFormTags: true, enableOnContentEditable: true },
[argumentsToSubmit, selectedCommand]
)
return (
selectedCommand &&
argumentsToSubmit && (
<>
<div className="px-4 text-sm flex gap-4 items-start">
<div className="flex flex-1 flex-wrap gap-2">
<p
data-command-name={selectedCommand?.name}
className="pr-4 flex gap-2 items-center"
>
{selectedCommand &&
'icon' in selectedCommand &&
selectedCommand.icon && (
<CustomIcon name={selectedCommand.icon} className="w-5 h-5" />
)}
{selectedCommand?.name}
</p>
{Object.entries(selectedCommand?.args || {})
.filter(([argName, _]) =>
selectedCommand?.args
? selectedCommand?.args[argName]?.required ||
(argName in argumentsToSubmit && argumentsToSubmit[argName])
: false
)
.map(([argName, arg], i) => (
<div className="relative group" key={argName}>
<button
disabled={!isReviewing && currentArgument?.name === argName}
onClick={() => {
commandBarSend({
type: isReviewing
? 'Edit argument'
: 'Change current argument',
data: { arg: { ...arg, name: argName } },
})
}}
className={`relative w-fit px-2 py-1 rounded-sm flex gap-2 items-center border ${
argName === currentArgument?.name
? 'disabled:bg-energy-10/50 dark:disabled:bg-energy-10/20 disabled:border-energy-10 dark:disabled:border-energy-10 disabled:text-chalkboard-100 dark:disabled:text-chalkboard-10'
: 'bg-chalkboard-20/50 dark:bg-chalkboard-80/50 border-chalkboard-20 dark:border-chalkboard-80'
}`}
>
{argumentsToSubmit[argName] ? (
arg.inputType === 'selection' ? (
getSelectionTypeDisplayText(
argumentsToSubmit[argName] as Selections
)
) : typeof argumentsToSubmit[argName] === 'object' ? (
JSON.stringify(argumentsToSubmit[argName])
) : (
argumentsToSubmit[argName]
)
) : arg.payload ? (
arg.inputType === 'selection' ? (
getSelectionTypeDisplayText(arg.payload as Selections)
) : typeof arg.payload === 'object' ? (
JSON.stringify(arg.payload)
) : (
arg.payload
)
) : (
<em>{argName}</em>
)}
{showShortcuts && (
<small className="absolute -top-[1px] right-full translate-x-1/2 px-0.5 rounded-sm bg-chalkboard-80 text-chalkboard-10 dark:bg-energy-10 dark:text-chalkboard-100">
<span className="sr-only">Hotkey: </span>
{i + 1}
</small>
)}
</button>
{!arg.required && (
<button
onClick={() => {
commandBarSend({
type: 'Remove argument',
data: { [argName]: { ...arg, name: argName } },
})
}}
className="invisible group-hover:visible absolute top-0 right-0 -translate-y-1/2 !p-0 flex items-center justify-center rounded-sm border-none bg-none"
>
<CustomIcon
name="close"
className="w-4 h-4 bg-destroy-80 dark:bg-destroy-30 hover:bg-destroy-70 dark:hover:bg-destroy-0 text-destroy-10 dark:text-destroy-80"
/>
<span className="sr-only">Remove argument</span>
</button>
)}
</div>
))}
</div>
{isReviewing ? <ReviewingButton /> : <GatheringArgsButton />}
</div>
<div className="block w-full my-2 h-[1px] bg-chalkboard-20 dark:bg-chalkboard-80" />
{children}
</>
)
)
}
function ReviewingButton() {
return (
<ActionButton
Element="button"
autoFocus
type="submit"
form="review-form"
className="w-fit !p-0 rounded-sm border !border-chalkboard-100 dark:!border-energy-10 hover:shadow"
icon={{
icon: 'checkmark',
bgClassName:
'p-1 rounded-sm !bg-chalkboard-100 hover:!bg-chalkboard-110 dark:!bg-energy-20 dark:hover:!bg-energy-10',
iconClassName: '!text-energy-10 dark:!text-chalkboard-100',
}}
>
<span className="sr-only">Submit command</span>
</ActionButton>
)
}
function GatheringArgsButton() {
return (
<ActionButton
Element="button"
type="submit"
form="arg-form"
className="w-fit !p-0 rounded-sm"
icon={{
icon: 'arrowRight',
bgClassName: 'p-1 rounded-sm',
}}
>
<span className="sr-only">Continue</span>
</ActionButton>
)
}
export default CommandBarHeader

View File

@ -0,0 +1,125 @@
import { useCommandsContext } from 'hooks/useCommandsContext'
import CommandBarHeader from './CommandBarHeader'
import { useHotkeys } from 'react-hotkeys-hook'
import { ActionButton } from 'components/ActionButton'
function CommandBarReview({ stepBack }: { stepBack: () => void }) {
const { commandBarState, commandBarSend } = useCommandsContext()
const {
context: { argumentsToSubmit, selectedCommand },
} = commandBarState
const optionalArgsNotAdded = Object.entries(
selectedCommand?.args || {}
).filter(
([key, val]) =>
selectedCommand?.args &&
!selectedCommand.args[key].required &&
!argumentsToSubmit[key]
)
useHotkeys('backspace', stepBack, {
enableOnFormTags: true,
enableOnContentEditable: true,
})
useHotkeys(
'1, 2, 3, 4, 5, 6, 7, 8, 9, 0',
(_, b) => {
if (b.keys && !Number.isNaN(parseInt(b.keys[0], 10))) {
if (!selectedCommand?.args) return
const argName = Object.keys(selectedCommand.args)[
parseInt(b.keys[0], 10) - 1
]
const arg = selectedCommand?.args[argName]
commandBarSend({
type: 'Edit argument',
data: { arg: { ...arg, name: argName } },
})
}
},
{ keyup: true, enableOnFormTags: true, enableOnContentEditable: true },
[argumentsToSubmit, selectedCommand]
)
Object.keys(argumentsToSubmit).forEach((key, i) => {
const arg = selectedCommand?.args ? selectedCommand?.args[key] : undefined
if (!arg) return
})
function submitCommand() {
commandBarSend({
type: 'Submit command',
data: argumentsToSubmit,
})
}
return (
<CommandBarHeader>
<p className="px-4 py-1">Confirm {selectedCommand?.name}</p>
<form
id="review-form"
className="absolute opacity-0 inset-0 pointer-events-none"
onSubmit={submitCommand}
>
{Object.entries(argumentsToSubmit).map(([key, value], i) => {
const arg = selectedCommand?.args
? selectedCommand?.args[key]
: undefined
if (!arg) return null
return (
<input
id={key}
name={key}
key={key}
type="text"
defaultValue={
typeof value === 'object'
? JSON.stringify(value)
: (value as string)
}
hidden
/>
)
})}
</form>
{optionalArgsNotAdded.length > 0 && (
<>
<div className="block w-full my-2 h-[1px] bg-chalkboard-20 dark:bg-chalkboard-80" />
<div className="flex flex-wrap px-4 gap-2 items-center">
{optionalArgsNotAdded.map(([key, _]) => {
const arg = selectedCommand?.args
? selectedCommand?.args[key]
: undefined
if (!arg) return null
return (
<ActionButton
Element="button"
key={key}
className="text-xs [&:not(:hover)]:border-transparent gap-0.5"
onClick={() => {
commandBarSend({
type: 'Edit argument',
data: { arg: { ...arg, name: key } },
})
}}
icon={{
icon: 'plus',
bgClassName: '!bg-transparent',
iconClassName:
'text-chalkboard-10 dark:text-chalkboard-100',
}}
>
{key}
</ActionButton>
)
})}
</div>
</>
)}
</CommandBarHeader>
)
}
export default CommandBarReview

View File

@ -0,0 +1,114 @@
import { useSelector } from '@xstate/react'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { useKclContext } from 'lang/KclSinglton'
import { CommandArgument } from 'lib/commandTypes'
import {
ResolvedSelectionType,
canSubmitSelectionArg,
getSelectionType,
getSelectionTypeDisplayText,
} from 'lib/selections'
import { modelingMachine } from 'machines/modelingMachine'
import { useEffect, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { StateFrom } from 'xstate'
const selectionSelector = (snapshot: StateFrom<typeof modelingMachine>) =>
snapshot.context.selectionRanges
function CommandBarSelectionInput({
arg,
stepBack,
onSubmit,
}: {
arg: CommandArgument<unknown> & { inputType: 'selection'; name: string }
stepBack: () => void
onSubmit: (data: unknown) => void
}) {
const { code } = useKclContext()
const inputRef = useRef<HTMLInputElement>(null)
const { commandBarSend } = useCommandsContext()
const [hasSubmitted, setHasSubmitted] = useState(false)
const selection = useSelector(arg.actor, selectionSelector)
const [selectionsByType, setSelectionsByType] = useState<
'none' | ResolvedSelectionType[]
>(
selection.codeBasedSelections[0]?.range[1] === code.length
? 'none'
: getSelectionType(selection)
)
const [canSubmitSelection, setCanSubmitSelection] = useState<boolean>(
canSubmitSelectionArg(selectionsByType, arg)
)
useHotkeys('tab', () => onSubmit(selection), {
enableOnFormTags: true,
enableOnContentEditable: true,
keyup: true,
})
useEffect(() => {
inputRef.current?.focus()
}, [selection, inputRef])
useEffect(() => {
setSelectionsByType(
selection.codeBasedSelections[0]?.range[1] === code.length
? 'none'
: getSelectionType(selection)
)
}, [selection])
useEffect(() => {
setCanSubmitSelection(canSubmitSelectionArg(selectionsByType, arg))
}, [selectionsByType, arg])
function handleChange() {
inputRef.current?.focus()
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!canSubmitSelection) {
setHasSubmitted(true)
return
}
onSubmit(selection)
}
return (
<form id="arg-form" onSubmit={handleSubmit}>
<label
className={
'relative flex items-center mx-4 my-4 ' +
(!hasSubmitted || canSubmitSelection || 'text-destroy-50')
}
>
{canSubmitSelection
? getSelectionTypeDisplayText(selection) + ' selected'
: `Please select ${arg.multiple ? 'one or more faces' : 'one face'}`}
<input
id="selection"
name="selection"
ref={inputRef}
required
placeholder="Select an entity with your mouse"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onKeyDown={(event) => {
if (event.key === 'Backspace') {
stepBack()
} else if (event.key === 'Escape') {
commandBarSend({ type: 'Close' })
}
}}
onChange={handleChange}
value={JSON.stringify(selection || {})}
/>
</label>
</form>
)
}
export default CommandBarSelectionInput

View File

@ -0,0 +1,90 @@
import { Combobox } from '@headlessui/react'
import Fuse from 'fuse.js'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { Command } from 'lib/commandTypes'
import { useEffect, useState } from 'react'
import { CustomIcon } from './CustomIcon'
function CommandComboBox({
options,
placeholder,
}: {
options: Command[]
placeholder?: string
}) {
const { commandBarSend } = useCommandsContext()
const [query, setQuery] = useState('')
const [filteredOptions, setFilteredOptions] = useState<typeof options>()
const defaultOption =
options.find((o) => 'isCurrent' in o && o.isCurrent) || null
const fuse = new Fuse(options, {
keys: ['name', 'description'],
threshold: 0.3,
})
useEffect(() => {
const results = fuse.search(query).map((result) => result.item)
setFilteredOptions(query.length > 0 ? results : options)
}, [query])
function handleSelection(command: Command) {
commandBarSend({ type: 'Select command', data: { command } })
}
return (
<Combobox defaultValue={defaultOption} onChange={handleSelection}>
<div className="flex items-center gap-2 px-4 pb-2 border-solid border-0 border-b border-b-chalkboard-20 dark:border-b-chalkboard-80">
<CustomIcon
name="search"
className="w-5 h-5 bg-energy-10/50 dark:bg-chalkboard-90 dark:text-energy-10"
/>
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
className="w-full bg-transparent focus:outline-none selection:bg-energy-10/50 dark:selection:bg-energy-10/20 dark:focus:outline-none"
onKeyDown={(event) => {
if (
(event.metaKey && event.key === 'k') ||
(event.key === 'Backspace' && !event.currentTarget.value)
) {
commandBarSend({ type: 'Close' })
}
}}
placeholder={
(defaultOption && defaultOption.name) ||
placeholder ||
'Search commands'
}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
autoFocus
/>
</div>
<Combobox.Options
static
className="overflow-y-auto max-h-96 cursor-pointer"
>
{filteredOptions?.map((option) => (
<Combobox.Option
key={option.name}
value={option}
className="flex items-center gap-2 px-4 py-1 first:mt-2 last:mb-2 ui-active:bg-energy-10/50 dark:ui-active:bg-chalkboard-90"
>
{'icon' in option && option.icon && (
<CustomIcon
name={option.icon}
className="w-5 h-5 dark:text-energy-10"
/>
)}
<p className="flex-grow">{option.name} </p>
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
)
}
export default CommandComboBox

View File

@ -1,14 +1,23 @@
export type CustomIconName = export type CustomIconName =
| 'createFile' | 'arrowDown'
| 'createFolder' | 'arrowLeft'
| 'arrowRight'
| 'arrowUp'
| 'checkmark'
| 'close'
| 'equal' | 'equal'
| 'exit'
| 'extrude' | 'extrude'
| 'file' | 'file'
| 'filePlus'
| 'folder'
| 'folderPlus'
| 'gear'
| 'horizontal' | 'horizontal'
| 'line' | 'line'
| 'move' | 'move'
| 'parallel' | 'parallel'
| 'plus'
| 'search'
| 'sketch' | 'sketch'
| 'vertical' | 'vertical'
@ -19,7 +28,7 @@ export const CustomIcon = ({
name: CustomIconName name: CustomIconName
} & React.SVGProps<SVGSVGElement>) => { } & React.SVGProps<SVGSVGElement>) => {
switch (name) { switch (name) {
case 'createFile': case 'arrowDown':
return ( return (
<svg <svg
{...props} {...props}
@ -30,12 +39,12 @@ export const CustomIcon = ({
<path <path
fillRule="evenodd" fillRule="evenodd"
clipRule="evenodd" clipRule="evenodd"
d="M4 3H4.5H11H11.2071L11.3536 3.14645L15.8536 7.64646L16 7.7929V8.00001V11.3773C15.6992 11.1362 15.3628 10.9376 15 10.7908V8.50001H11H10.5V8.00001V4H5V16H9.79076C9.93763 16.3628 10.1362 16.6992 10.3773 17H4.5H4V16.5V3.5V3ZM11.5 4.70711L14.2929 7.50001H11.5V4.70711ZM13 12V14H11V15H13V17H14V15H16V14H14V12H13Z" d="M10 17.7071L9.64648 17.3535L6.14648 13.8535L6.85359 13.1464L9.50004 15.7929V2.99997H10.5V15.7929L13.1465 13.1464L13.8536 13.8535L10.3536 17.3535L10 17.7071Z"
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
) )
case 'createFolder': case 'arrowLeft':
return ( return (
<svg <svg
{...props} {...props}
@ -46,7 +55,71 @@ export const CustomIcon = ({
<path <path
fillRule="evenodd" fillRule="evenodd"
clipRule="evenodd" clipRule="evenodd"
d="M3.5 3.5H4H7H7.16667L7.3 3.6L9.16667 5H16H16.5V5.5V7.5V10.3773C16.1992 10.1362 15.8628 9.93763 15.5 9.79076V8H4.5V15.5H10.5351C10.7529 15.8764 11.0302 16.2141 11.3542 16.5H4H3.5V16V7.5V4V3.5ZM4.5 4.5V7H15.5V6H9H8.83333L8.7 5.9L6.83333 4.5H4.5ZM13.5 11V13H11.5V14H13.5V16H14.5V14H16.5V13H14.5V11H13.5Z" d="M2.29291 10L2.64646 9.64645L6.14646 6.14645L6.85357 6.85356L4.20712 9.50001L17 9.50001V10.5L4.20712 10.5L6.85357 13.1465L6.14646 13.8536L2.64646 10.3536L2.29291 10Z"
fill="currentColor"
/>
</svg>
)
case 'arrowRight':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17.7071 10L17.3536 10.3536L13.8536 13.8536L13.1464 13.1465L15.7929 10.5H3V9.50001H15.7929L13.1464 6.85356L13.8536 6.14645L17.3536 9.64645L17.7071 10Z"
fill="currentColor"
/>
</svg>
)
case 'arrowUp':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 2.29288L10.3536 2.64643L13.8536 6.14643L13.1465 6.85354L10.5 4.20709V17H9.50004V4.20709L6.85359 6.85354L6.14648 6.14643L9.64648 2.64643L10 2.29288Z"
fill="currentColor"
/>
</svg>
)
case 'checkmark':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.29956 13.5388L13.9537 6L14.7537 6.6L8.75367 14.6L8.00012 14.6536L5 11.6536L5.70709 10.9465L8.29956 13.5388Z"
fill="currentColor"
/>
</svg>
)
case 'close':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.2929 10L6.46448 7.17158L7.17158 6.46448L10 9.2929L12.8284 6.46448L13.5355 7.17158L10.7071 10L13.5355 12.8284L12.8284 13.5355L10 10.7071L7.17158 13.5355L6.46448 12.8284L9.2929 10Z"
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
@ -65,21 +138,6 @@ export const CustomIcon = ({
/> />
</svg> </svg>
) )
case 'exit':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17 10L3 10M3 10L6.5 6.5M3 10L6.5 13.5"
stroke="currentColor"
/>
</svg>
)
case 'extrude': case 'extrude':
return ( return (
<svg <svg
@ -105,8 +163,74 @@ export const CustomIcon = ({
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
d="M11 3.5H4.5V16.5H15.5V8.00001M11 3.5L15.5 8.00001M11 3.5V8.00001H15.5" fillRule="evenodd"
stroke="currentColor" clipRule="evenodd"
d="M4 3H4.5H11H11.2071L11.3536 3.14645L15.8536 7.64646L16 7.7929V8.00001V16.5V17H15.5H4.5H4V16.5V3.5V3ZM5 4V16H15V8.50001H11H10.5V8.00001V4H5ZM11.5 4.70711L14.2929 7.50001H11.5V4.70711Z"
fill="currentColor"
/>
</svg>
)
case 'filePlus':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 3H4.5H11H11.2071L11.3536 3.14645L15.8536 7.64646L16 7.7929V8.00001V11.3773C15.6992 11.1362 15.3628 10.9376 15 10.7908V8.50001H11H10.5V8.00001V4H5V16H9.79076C9.93763 16.3628 10.1362 16.6992 10.3773 17H4.5H4V16.5V3.5V3ZM11.5 4.70711L14.2929 7.50001H11.5V4.70711ZM13 12V14H11V15H13V17H14V15H16V14H14V12H13Z"
fill="currentColor"
/>
</svg>
)
case 'folder':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.5 3.5H4H7H7.16667L7.3 3.6L9.16667 5H16H16.5V5.5V7.5V16V16.5H16H4H3.5V16V7.5V4V3.5ZM4.5 4.5V7H15.5V6H9H8.83333L8.7 5.9L6.83333 4.5H4.5ZM15.5 8H4.5V15.5H15.5V8Z"
fill="currentColor"
/>
</svg>
)
case 'folderPlus':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.5 3.5H4H7H7.16667L7.3 3.6L9.16667 5H16H16.5V5.5V7.5V10.3773C16.1992 10.1362 15.8628 9.93763 15.5 9.79076V8H4.5V15.5H10.5351C10.7529 15.8764 11.0302 16.2141 11.3542 16.5H4H3.5V16V7.5V4V3.5ZM4.5 4.5V7H15.5V6H9H8.83333L8.7 5.9L6.83333 4.5H4.5ZM13.5 11V13H11.5V14H13.5V16H14.5V14H16.5V13H14.5V11H13.5Z"
fill="currentColor"
/>
</svg>
)
case 'gear':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.61477 3.0884L5.87402 4.67077L6.50004 5.75505L5.25004 7.92011H4.0047V11.07H5.25004L6.50004 13.2351L5.86973 14.3268L8.62776 15.9191L9.24503 14.85H11.745L12.3647 15.9234L15.1416 14.3202L14.5151 13.2351L15.7651 11.07H16.9951V7.92011H15.7651L14.5151 5.75505L15.1373 4.67741L12.3778 3.08423L11.7451 4.18012H9.24508L8.61477 3.0884ZM10.4999 13C12.4329 13 13.9999 11.433 13.9999 9.50003C13.9999 7.56703 12.4329 6.00003 10.4999 6.00003C8.56687 6.00003 6.99986 7.56703 6.99986 9.50003C6.99986 11.433 8.56687 13 10.4999 13Z"
fill="currentColor"
/> />
</svg> </svg>
) )
@ -174,6 +298,38 @@ export const CustomIcon = ({
/> />
</svg> </svg>
) )
case 'plus':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.5 9.5V5.5H10.5V9.5H14.5V10.5H10.5V14.5H9.5V10.5H5.5V9.5H9.5Z"
fill="currentColor"
/>
</svg>
)
case 'search':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.016 9.00482C14.016 10.662 12.6731 12.0048 11.0172 12.0048C9.3613 12.0048 8.01841 10.662 8.01841 9.00482C8.01841 7.34768 9.3613 6.00482 11.0172 6.00482C12.6731 6.00482 14.016 7.34768 14.016 9.00482ZM15.016 9.00482C15.016 11.214 13.2257 13.0048 11.0172 13.0048C10.082 13.0048 9.22178 12.6837 8.54074 12.1456L5.6912 14.9952L4.98409 14.2881L7.83921 11.433C7.32431 10.7597 7.01841 9.91799 7.01841 9.00482C7.01841 6.79568 8.80873 5.00482 11.0172 5.00482C13.2257 5.00482 15.016 6.79568 15.016 9.00482Z"
fill="currentColor"
/>
</svg>
)
case 'sketch': case 'sketch':
return ( return (
<svg <svg

View File

@ -1,7 +1,6 @@
import { Dialog } from '@headlessui/react' import { Dialog } from '@headlessui/react'
import { useStore } from '../useStore' import { useStore } from '../useStore'
import { ActionButton } from './ActionButton' import { ActionButton } from './ActionButton'
import { faX } from '@fortawesome/free-solid-svg-icons'
const DownloadAppBanner = () => { const DownloadAppBanner = () => {
const { isBannerDismissed, setBannerDismissed } = useStore((s) => ({ const { isBannerDismissed, setBannerDismissed } = useStore((s) => ({
@ -11,44 +10,48 @@ const DownloadAppBanner = () => {
return ( return (
<Dialog <Dialog
className="fixed inset-0 top-auto z-50 bg-warn-20 text-warn-80 px-8 py-4" className="fixed inset-0 z-50"
open={!isBannerDismissed} open={!isBannerDismissed}
onClose={() => ({})} onClose={() => ({})}
> >
<Dialog.Panel className="max-w-3xl mx-auto"> <Dialog.Overlay className="fixed inset-0 bg-chalkboard-100/50" />
<div className="flex gap-2 justify-between items-start"> <Dialog.Panel className="absolute inset-0 top-auto bg-warn-20 text-warn-80 px-8 py-4">
<h2 className="text-xl font-bold mb-4"> <div className="max-w-3xl mx-auto">
KittyCAD Modeling App is better as a desktop app! <div className="flex gap-2 justify-between items-start">
</h2> <h2 className="text-xl font-bold mb-4">
<ActionButton KittyCAD Modeling App is better as a desktop app!
Element="button" </h2>
onClick={() => setBannerDismissed(true)} <ActionButton
icon={{ Element="button"
icon: faX, onClick={() => setBannerDismissed(true)}
bgClassName: icon={{
'bg-warn-70 hover:bg-warn-80 dark:bg-warn-70 dark:hover:bg-warn-80', icon: 'close',
iconClassName: className: 'p-1',
'text-warn-10 group-hover:text-warn-10 dark:text-warn-10 dark:group-hover:text-warn-10', bgClassName:
}} 'bg-warn-70 hover:bg-warn-80 dark:bg-warn-70 dark:hover:bg-warn-80',
className="!p-0 !bg-transparent !border-transparent" iconClassName:
/> 'text-warn-10 group-hover:text-warn-10 dark:text-warn-10 dark:group-hover:text-warn-10',
}}
className="!p-0 !bg-transparent !border-transparent"
/>
</div>
<p>
The browser version of the app only saves your data temporarily in{' '}
<code className="text-base inline-block px-0.5 bg-warn-30/50 rounded">
localStorage
</code>
, and isn't backed up anywhere! Visit{' '}
<a
href="https://kittycad.io/modeling-app/download"
rel="noopener noreferrer"
target="_blank"
className="!text-warn-80 dark:!text-warn-80 dark:hover:!text-warn-70 underline"
>
our website
</a>{' '}
to download the app for the best experience.
</p>
</div> </div>
<p>
The browser version of the app only saves your data temporarily in{' '}
<code className="text-base inline-block px-0.5 bg-warn-30/50 rounded">
localStorage
</code>
, and isn't backed up anywhere! Visit{' '}
<a
href="https://kittycad.io/modeling-app/download"
rel="noopener noreferrer"
target="_blank"
className="text-warn-80 dark:text-warn-80 dark:hover:text-warn-70 underline"
>
our website
</a>{' '}
to download the app for the best experience.
</p>
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) )

View File

@ -118,6 +118,8 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
Element="button" Element="button"
icon={{ icon={{
icon: faFileExport, icon: faFileExport,
className: 'p-1',
size: 'sm',
iconClassName: className?.icon, iconClassName: className?.icon,
bgClassName: className?.bg, bgClassName: className?.bg,
}} }}
@ -212,6 +214,7 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
onClick={closeModal} onClick={closeModal}
icon={{ icon={{
icon: faXmark, icon: faXmark,
className: 'p-1',
bgClassName: 'bg-destroy-80', bgClassName: 'bg-destroy-80',
iconClassName: iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10', 'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
@ -223,7 +226,7 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
<ActionButton <ActionButton
Element="button" Element="button"
type="submit" type="submit"
icon={{ icon: faFileExport }} icon={{ icon: faFileExport, className: 'p-1' }}
> >
Export Export
</ActionButton> </ActionButton>

View File

@ -40,7 +40,7 @@ export const FileMachineProvider = ({
children: React.ReactNode children: React.ReactNode
}) => { }) => {
const navigate = useNavigate() const navigate = useNavigate()
const { setCommandBarOpen } = useCommandsContext() const { commandBarSend } = useCommandsContext()
const { project } = useRouteLoaderData(paths.FILE) as IndexLoaderData const { project } = useRouteLoaderData(paths.FILE) as IndexLoaderData
const [state, send] = useMachine(fileMachine, { const [state, send] = useMachine(fileMachine, {
@ -54,7 +54,7 @@ export const FileMachineProvider = ({
event: EventFrom<typeof fileMachine> event: EventFrom<typeof fileMachine>
) => { ) => {
if (event.data && 'name' in event.data) { if (event.data && 'name' in event.data) {
setCommandBarOpen(false) commandBarSend({ type: 'Close' })
navigate( navigate(
`${paths.FILE}/${encodeURIComponent( `${paths.FILE}/${encodeURIComponent(
context.selectedDirectory + sep + event.data.name context.selectedDirectory + sep + event.data.name

View File

@ -325,16 +325,17 @@ export const FileTree = ({
return ( return (
<div className={className}> <div className={className}>
<div className="flex items-center gap-1 px-4 py-1 bg-chalkboard-30/50 dark:bg-chalkboard-70/50"> <div className="flex items-center gap-1 px-4 py-1 bg-chalkboard-20/50 dark:bg-chalkboard-80/50 border-b border-b-chalkboard-30 dark:border-b-chalkboard-80">
<h2 className="flex-1 m-0 p-0 text-sm mono">Files</h2> <h2 className="flex-1 m-0 p-0 text-sm mono">Files</h2>
<ActionButton <ActionButton
Element="button" Element="button"
icon={{ icon={{
icon: 'createFile', icon: 'filePlus',
iconClassName: '!text-energy-80 dark:!text-energy-20', iconClassName: '!text-energy-80 dark:!text-energy-20',
bgClassName: 'hover:bg-energy-10/50 dark:hover:bg-transparent', bgClassName:
'bg-chalkboard-20/50 hover:bg-energy-10/50 dark:hover:bg-transparent',
}} }}
className="!p-0 border-none bg-transparent !outline-none" className="!p-0 bg-transparent !outline-none"
onClick={createFile} onClick={createFile}
> >
<Tooltip position="inlineStart" delay={750}> <Tooltip position="inlineStart" delay={750}>
@ -345,11 +346,12 @@ export const FileTree = ({
<ActionButton <ActionButton
Element="button" Element="button"
icon={{ icon={{
icon: 'createFolder', icon: 'folderPlus',
iconClassName: '!text-energy-80 dark:!text-energy-20', iconClassName: '!text-energy-80 dark:!text-energy-20',
bgClassName: 'hover:bg-energy-10/50 dark:hover:bg-transparent', bgClassName:
'bg-chalkboard-20/50 hover:bg-energy-10/50 dark:hover:bg-transparent',
}} }}
className="!p-0 border-none bg-transparent !outline-none" className="!p-0 bg-transparent !outline-none"
onClick={createFolder} onClick={createFolder}
> >
<Tooltip position="inlineStart" delay={750}> <Tooltip position="inlineStart" delay={750}>

View File

@ -1,19 +1,11 @@
import { useMachine } from '@xstate/react' import { useMachine } from '@xstate/react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { paths } from '../Router' import { paths } from '../Router'
import { import { authMachine, TOKEN_PERSIST_KEY } from '../machines/authMachine'
authCommandBarMeta,
authMachine,
TOKEN_PERSIST_KEY,
} from '../machines/authMachine'
import withBaseUrl from '../lib/withBaseURL' import withBaseUrl from '../lib/withBaseURL'
import React, { createContext, useEffect, useRef } from 'react' import React, { createContext, useEffect, useRef } from 'react'
import useStateMachineCommands from '../hooks/useStateMachineCommands' import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { import { SETTINGS_PERSIST_KEY, settingsMachine } from 'machines/settingsMachine'
SETTINGS_PERSIST_KEY,
settingsCommandBarMeta,
settingsMachine,
} from 'machines/settingsMachine'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { setThemeClass, Themes } from 'lib/theme' import { setThemeClass, Themes } from 'lib/theme'
import { import {
@ -23,8 +15,9 @@ import {
Prop, Prop,
StateFrom, StateFrom,
} from 'xstate' } from 'xstate'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { settingsCommandBarConfig } from 'lib/commandBarConfigs/settingsCommandConfig'
import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -45,7 +38,6 @@ export const GlobalStateProvider = ({
children: React.ReactNode children: React.ReactNode
}) => { }) => {
const navigate = useNavigate() const navigate = useNavigate()
const { commands } = useCommandsContext()
// Settings machine setup // Settings machine setup
const retrievedSettings = useRef( const retrievedSettings = useRef(
@ -81,11 +73,10 @@ export const GlobalStateProvider = ({
}) })
useStateMachineCommands({ useStateMachineCommands({
machineId: 'settings',
state: settingsState, state: settingsState,
send: settingsSend, send: settingsSend,
commands, commandBarConfig: settingsCommandBarConfig,
owner: 'settings',
commandBarMeta: settingsCommandBarMeta,
}) })
// Listen for changes to the system theme and update the app theme accordingly // Listen for changes to the system theme and update the app theme accordingly
@ -121,11 +112,10 @@ export const GlobalStateProvider = ({
}) })
useStateMachineCommands({ useStateMachineCommands({
machineId: 'auth',
state: authState, state: authState,
send: authSend, send: authSend,
commands, commandBarConfig: authCommandBarConfig,
commandBarMeta: authCommandBarMeta,
owner: 'auth',
}) })
return ( return (

View File

@ -15,23 +15,23 @@ const Loading = ({ children }: React.PropsWithChildren) => {
data-testid="loading" data-testid="loading"
> >
<svg viewBox="0 0 10 10" className="w-8 h-8"> <svg viewBox="0 0 10 10" className="w-8 h-8">
<circle cx="5" cy="5" r="4" stroke="var(--liquid-20)" fill="none" /> <circle cx="5" cy="5" r="4" stroke="var(--energy-50)" fill="none" />
<circle <circle
cx="5" cx="5"
cy="5" cy="5"
r="4" r="4"
stroke="var(--liquid-10)" stroke="var(--energy-10)"
fill="none" fill="none"
strokeDasharray="4, 4" strokeDasharray="4, 4"
className="animate-spin origin-center" className="animate-spin origin-center"
/> />
</svg> </svg>
<p className="text-base mt-4 text-liquid-80 dark:text-liquid-20"> <p className="text-base mt-4 text-energy-80 dark:text-energy-30">
{children || 'Loading'} {children || 'Loading'}
</p> </p>
<p <p
className={ className={
'text-sm mt-4 text-liquid-90 dark:text-liquid-10 transition-opacity duration-500' + 'text-sm mt-4 text-energy-70 dark:text-energy-50 transition-opacity duration-500' +
(hasLongLoadTime ? ' opacity-100' : ' opacity-0') (hasLongLoadTime ? ' opacity-100' : ' opacity-0')
} }
> >

View File

@ -29,19 +29,26 @@ import {
addNewSketchLn, addNewSketchLn,
compareVec2Epsilon, compareVec2Epsilon,
} from 'lang/std/sketch' } from 'lang/std/sketch'
import { kclManager } from 'lang/KclSinglton' import { kclManager, useKclContext } from 'lang/KclSinglton'
import { applyConstraintHorzVertDistance } from './Toolbar/SetHorzVertDistance' import { applyConstraintHorzVertDistance } from './Toolbar/SetHorzVertDistance'
import { import {
angleBetweenInfo, angleBetweenInfo,
applyConstraintAngleBetween, applyConstraintAngleBetween,
} from './Toolbar/SetAngleBetween' } from './Toolbar/SetAngleBetween'
import { applyConstraintAngleLength } from './Toolbar/setAngleLength' import { applyConstraintAngleLength } from './Toolbar/setAngleLength'
import { toast } from 'react-hot-toast'
import { pathMapToSelections } from 'lang/util' import { pathMapToSelections } from 'lang/util'
import { useStore } from 'useStore' import { useStore } from 'useStore'
import { handleSelectionBatch, handleSelectionWithShift } from 'lib/selections' import {
canExtrudeSelection,
handleSelectionBatch,
handleSelectionWithShift,
isSelectionLastLine,
isSketchPipe,
} from 'lib/selections'
import { applyConstraintIntersect } from './Toolbar/Intersect' import { applyConstraintIntersect } from './Toolbar/Intersect'
import { applyConstraintAbsDistance } from './Toolbar/SetAbsDistance' import { applyConstraintAbsDistance } from './Toolbar/SetAbsDistance'
import useStateMachineCommands from 'hooks/useStateMachineCommands'
import { modelingMachineConfig } from 'lib/commandBarConfigs/modelingCommandConfig'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -59,6 +66,7 @@ export const ModelingMachineProvider = ({
children: React.ReactNode children: React.ReactNode
}) => { }) => {
const { auth } = useGlobalStateContext() const { auth } = useGlobalStateContext()
const { code } = useKclContext()
const token = auth?.context?.token const token = auth?.context?.token
const streamRef = useRef<HTMLDivElement>(null) const streamRef = useRef<HTMLDivElement>(null)
useSetupEngineManager(streamRef, token) useSetupEngineManager(streamRef, token)
@ -68,8 +76,6 @@ export const ModelingMachineProvider = ({
editorView: s.editorView, editorView: s.editorView,
})) }))
// const { commands } = useCommandsContext()
// Settings machine setup // Settings machine setup
// const retrievedSettings = useRef( // const retrievedSettings = useRef(
// localStorage?.getItem(MODELING_PERSIST_KEY) || '{}' // localStorage?.getItem(MODELING_PERSIST_KEY) || '{}'
@ -83,148 +89,85 @@ export const ModelingMachineProvider = ({
// > // >
// ) // )
const [modelingState, modelingSend] = useMachine(modelingMachine, { const [modelingState, modelingSend, modelingActor] = useMachine(
// context: persistedSettings, modelingMachine,
actions: { {
'Modify AST': () => {}, // context: persistedSettings,
'Update code selection cursors': () => {}, actions: {
'show default planes': () => { 'Modify AST': () => {},
kclManager.showPlanes() 'Update code selection cursors': () => {},
}, 'show default planes': () => {
'create path': assign({ kclManager.showPlanes()
sketchEnginePathId: () => {
const sketchUuid = uuidv4()
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: sketchUuid,
cmd: {
type: 'start_path',
},
})
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'edit_mode_enter',
target: sketchUuid,
},
})
return sketchUuid
}, },
}), 'create path': assign({
'AST start new sketch': assign( sketchEnginePathId: () => {
({ sketchEnginePathId }, { data: { coords, axis, segmentId } }) => { const sketchUuid = uuidv4()
if (!axis) { engineCommandManager.sendSceneCommand({
// Something really weird must have happened for this to happen. type: 'modeling_cmd_req',
console.error('axis is undefined for starting a new sketch') cmd_id: sketchUuid,
return {} cmd: {
} type: 'start_path',
if (!segmentId) { },
// Something really weird must have happened for this to happen. })
console.error('segmentId is undefined for starting a new sketch') engineCommandManager.sendSceneCommand({
return {} type: 'modeling_cmd_req',
} cmd_id: uuidv4(),
cmd: {
const _addStartSketch = addStartSketch( type: 'edit_mode_enter',
kclManager.ast, target: sketchUuid,
axis, },
[roundOff(coords[0].x), roundOff(coords[0].y)], })
[ return sketchUuid
roundOff(coords[1].x - coords[0].x), },
roundOff(coords[1].y - coords[0].y), }),
] 'AST start new sketch': assign(
) ({ sketchEnginePathId }, { data: { coords, axis, segmentId } }) => {
const _modifiedAst = _addStartSketch.modifiedAst if (!axis) {
const _pathToNode = _addStartSketch.pathToNode // Something really weird must have happened for this to happen.
const newCode = recast(_modifiedAst) console.error('axis is undefined for starting a new sketch')
const astWithUpdatedSource = parse(newCode) return {}
const updatedPipeNode = getNodeFromPath<PipeExpression>(
astWithUpdatedSource,
_pathToNode
).node
const startProfileAtCallExp = updatedPipeNode.body.find(
(exp) =>
exp.type === 'CallExpression' &&
exp.callee.name === 'startProfileAt'
)
if (startProfileAtCallExp)
engineCommandManager.artifactMap[sketchEnginePathId] = {
type: 'result',
range: [startProfileAtCallExp.start, startProfileAtCallExp.end],
commandType: 'start_path',
data: null,
raw: {} as any,
} }
const lineCallExp = updatedPipeNode.body.find( if (!segmentId) {
(exp) => exp.type === 'CallExpression' && exp.callee.name === 'line' // Something really weird must have happened for this to happen.
) console.error('segmentId is undefined for starting a new sketch')
if (lineCallExp) return {}
engineCommandManager.artifactMap[segmentId] = {
type: 'result',
range: [lineCallExp.start, lineCallExp.end],
commandType: 'extend_path',
parentId: sketchEnginePathId,
data: null,
raw: {} as any,
} }
kclManager.executeAstMock(astWithUpdatedSource, true) const _addStartSketch = addStartSketch(
return {
sketchPathToNode: _pathToNode,
}
}
),
'AST add line segment': async (
{ sketchPathToNode, sketchEnginePathId },
{ data: { coords, segmentId } }
) => {
if (!sketchPathToNode) return
const lastCoord = coords[coords.length - 1]
const pathInfo = await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'path_get_info',
path_id: sketchEnginePathId,
},
})
const firstSegment = pathInfo?.data?.data?.segments.find(
(seg: any) => seg.command === 'line_to'
)
const firstSegCoords = await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'curve_get_control_points',
curve_id: firstSegment.command_id,
},
})
const startPathCoord = firstSegCoords?.data?.data?.control_points[0]
const isClose = compareVec2Epsilon(
[startPathCoord.x, startPathCoord.y],
[lastCoord.x, lastCoord.y]
)
let _modifiedAst: Program
if (!isClose) {
const newSketchLn = addNewSketchLn({
node: kclManager.ast,
programMemory: kclManager.programMemory,
to: [lastCoord.x, lastCoord.y],
from: [coords[0].x, coords[0].y],
fnName: 'line',
pathToNode: sketchPathToNode,
})
const _modifiedAst = newSketchLn.modifiedAst
kclManager.executeAstMock(_modifiedAst, true).then(() => {
const lineCallExp = getNodeFromPath<CallExpression>(
kclManager.ast, kclManager.ast,
newSketchLn.pathToNode axis,
[roundOff(coords[0].x), roundOff(coords[0].y)],
[
roundOff(coords[1].x - coords[0].x),
roundOff(coords[1].y - coords[0].y),
]
)
const _modifiedAst = _addStartSketch.modifiedAst
const _pathToNode = _addStartSketch.pathToNode
const newCode = recast(_modifiedAst)
const astWithUpdatedSource = parse(newCode)
const updatedPipeNode = getNodeFromPath<PipeExpression>(
astWithUpdatedSource,
_pathToNode
).node ).node
if (segmentId) const startProfileAtCallExp = updatedPipeNode.body.find(
(exp) =>
exp.type === 'CallExpression' &&
exp.callee.name === 'startProfileAt'
)
if (startProfileAtCallExp)
engineCommandManager.artifactMap[sketchEnginePathId] = {
type: 'result',
range: [startProfileAtCallExp.start, startProfileAtCallExp.end],
commandType: 'start_path',
data: null,
raw: {} as any,
}
const lineCallExp = updatedPipeNode.body.find(
(exp) =>
exp.type === 'CallExpression' && exp.callee.name === 'line'
)
if (lineCallExp)
engineCommandManager.artifactMap[segmentId] = { engineCommandManager.artifactMap[segmentId] = {
type: 'result', type: 'result',
range: [lineCallExp.start, lineCallExp.end], range: [lineCallExp.start, lineCallExp.end],
@ -233,120 +176,189 @@ export const ModelingMachineProvider = ({
data: null, data: null,
raw: {} as any, raw: {} as any,
} }
})
} else {
_modifiedAst = addCloseToPipe({
node: kclManager.ast,
programMemory: kclManager.programMemory,
pathToNode: sketchPathToNode,
})
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'edit_mode_exit' },
})
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'default_camera_disable_sketch_mode' },
})
kclManager.executeAstMock(_modifiedAst, true)
// updateAst(_modifiedAst, true)
}
},
'sketch exit execute': () => {
kclManager.executeAst()
},
'set tool': () => {}, // TODO
'toast extrude failed': () => {
toast.error(
'Extrude failed, sketches need to be closed, or not already extruded'
)
},
'Set selection': assign(({ selectionRanges }, event) => {
if (event.type !== 'Set selection') return {} // this was needed for ts after adding 'Set selection' action to on done modal events
const setSelections = event.data
if (!editorView) return {}
if (setSelections.selectionType === 'mirrorCodeMirrorSelections')
return { selectionRanges: setSelections.selection }
else if (setSelections.selectionType === 'otherSelection') {
// TODO KittyCAD/engine/issues/1620: send axis highlight when it's working (if that's what we settle on)
// const axisAddCmd: EngineCommand = {
// type: 'modeling_cmd_req',
// cmd: {
// type: 'highlight_set_entities',
// entities: [
// setSelections.selection === 'x-axis'
// ? X_AXIS_UUID
// : Y_AXIS_UUID,
// ],
// },
// cmd_id: uuidv4(),
// }
// if (!isShiftDown) { kclManager.executeAstMock(astWithUpdatedSource, true)
// engineCommandManager
// .sendSceneCommand({
// type: 'modeling_cmd_req',
// cmd: {
// type: 'select_clear',
// },
// cmd_id: uuidv4(),
// })
// .then(() => {
// engineCommandManager.sendSceneCommand(axisAddCmd)
// })
// } else {
// engineCommandManager.sendSceneCommand(axisAddCmd)
// }
const { return {
codeMirrorSelection, sketchPathToNode: _pathToNode,
selectionRangeTypeMap, }
otherSelections,
} = handleSelectionWithShift({
otherSelection: setSelections.selection,
currentSelections: selectionRanges,
isShiftDown,
})
setTimeout(() => {
editorView.dispatch({
selection: codeMirrorSelection,
})
})
return {
selectionRangeTypeMap,
selectionRanges: {
codeBasedSelections: selectionRanges.codeBasedSelections,
otherSelections,
},
} }
} else if (setSelections.selectionType === 'singleCodeCursor') { ),
// This DOES NOT set the `selectionRanges` in xstate context 'AST add line segment': async (
// instead it updates/dispatches to the editor, which in turn updates the xstate context { sketchPathToNode, sketchEnginePathId },
// I've found this the best way to deal with the editor without causing an infinite loop { data: { coords, segmentId } }
// and really we want the editor to be in charge of cursor positions and for `selectionRanges` mirror it ) => {
// because we want to respect the user manually placing the cursor too. if (!sketchPathToNode) return
const lastCoord = coords[coords.length - 1]
// for more details on how selections see `src/lib/selections.ts`. const pathInfo = await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
const { cmd_id: uuidv4(),
codeMirrorSelection, cmd: {
selectionRangeTypeMap, type: 'path_get_info',
otherSelections, path_id: sketchEnginePathId,
} = handleSelectionWithShift({ },
codeSelection: setSelections.selection,
currentSelections: selectionRanges,
isShiftDown,
}) })
if (codeMirrorSelection) { const firstSegment = pathInfo?.data?.data?.segments.find(
(seg: any) => seg.command === 'line_to'
)
const firstSegCoords = await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'curve_get_control_points',
curve_id: firstSegment.command_id,
},
})
const startPathCoord = firstSegCoords?.data?.data?.control_points[0]
const isClose = compareVec2Epsilon(
[startPathCoord.x, startPathCoord.y],
[lastCoord.x, lastCoord.y]
)
let _modifiedAst: Program
if (!isClose) {
const newSketchLn = addNewSketchLn({
node: kclManager.ast,
programMemory: kclManager.programMemory,
to: [lastCoord.x, lastCoord.y],
from: [coords[0].x, coords[0].y],
fnName: 'line',
pathToNode: sketchPathToNode,
})
const _modifiedAst = newSketchLn.modifiedAst
kclManager.executeAstMock(_modifiedAst, true).then(() => {
const lineCallExp = getNodeFromPath<CallExpression>(
kclManager.ast,
newSketchLn.pathToNode
).node
if (segmentId)
engineCommandManager.artifactMap[segmentId] = {
type: 'result',
range: [lineCallExp.start, lineCallExp.end],
commandType: 'extend_path',
parentId: sketchEnginePathId,
data: null,
raw: {} as any,
}
})
} else {
_modifiedAst = addCloseToPipe({
node: kclManager.ast,
programMemory: kclManager.programMemory,
pathToNode: sketchPathToNode,
})
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'edit_mode_exit' },
})
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'default_camera_disable_sketch_mode' },
})
kclManager.executeAstMock(_modifiedAst, true)
// updateAst(_modifiedAst, true)
}
},
'sketch exit execute': () => {
kclManager.executeAst()
},
'set tool': () => {}, // TODO
'Set selection': assign(({ selectionRanges }, event) => {
if (event.type !== 'Set selection') return {} // this was needed for ts after adding 'Set selection' action to on done modal events
const setSelections = event.data
if (!editorView) return {}
if (setSelections.selectionType === 'mirrorCodeMirrorSelections')
return { selectionRanges: setSelections.selection }
else if (setSelections.selectionType === 'otherSelection') {
// TODO KittyCAD/engine/issues/1620: send axis highlight when it's working (if that's what we settle on)
// const axisAddCmd: EngineCommand = {
// type: 'modeling_cmd_req',
// cmd: {
// type: 'highlight_set_entities',
// entities: [
// setSelections.selection === 'x-axis'
// ? X_AXIS_UUID
// : Y_AXIS_UUID,
// ],
// },
// cmd_id: uuidv4(),
// }
// if (!isShiftDown) {
// engineCommandManager
// .sendSceneCommand({
// type: 'modeling_cmd_req',
// cmd: {
// type: 'select_clear',
// },
// cmd_id: uuidv4(),
// })
// .then(() => {
// engineCommandManager.sendSceneCommand(axisAddCmd)
// })
// } else {
// engineCommandManager.sendSceneCommand(axisAddCmd)
// }
const {
codeMirrorSelection,
selectionRangeTypeMap,
otherSelections,
} = handleSelectionWithShift({
otherSelection: setSelections.selection,
currentSelections: selectionRanges,
isShiftDown,
})
setTimeout(() => { setTimeout(() => {
editorView.dispatch({ editorView.dispatch({
selection: codeMirrorSelection, selection: codeMirrorSelection,
}) })
}) })
} return {
if (!setSelections.selection) { selectionRangeTypeMap,
selectionRanges: {
codeBasedSelections: selectionRanges.codeBasedSelections,
otherSelections,
},
}
} else if (setSelections.selectionType === 'singleCodeCursor') {
// This DOES NOT set the `selectionRanges` in xstate context
// instead it updates/dispatches to the editor, which in turn updates the xstate context
// I've found this the best way to deal with the editor without causing an infinite loop
// and really we want the editor to be in charge of cursor positions and for `selectionRanges` mirror it
// because we want to respect the user manually placing the cursor too.
// for more details on how selections see `src/lib/selections.ts`.
const {
codeMirrorSelection,
selectionRangeTypeMap,
otherSelections,
} = handleSelectionWithShift({
codeSelection: setSelections.selection,
currentSelections: selectionRanges,
isShiftDown,
})
if (codeMirrorSelection) {
setTimeout(() => {
editorView.dispatch({
selection: codeMirrorSelection,
})
})
}
if (!setSelections.selection) {
return {
selectionRangeTypeMap,
selectionRanges: {
codeBasedSelections: selectionRanges.codeBasedSelections,
otherSelections,
},
}
}
return { return {
selectionRangeTypeMap, selectionRangeTypeMap,
selectionRanges: { selectionRanges: {
@ -355,171 +367,180 @@ export const ModelingMachineProvider = ({
}, },
} }
} }
// This DOES NOT set the `selectionRanges` in xstate context
// same as comment above
const { codeMirrorSelection, selectionRangeTypeMap } =
handleSelectionBatch({
selections: setSelections.selection,
})
if (codeMirrorSelection) {
setTimeout(() => {
editorView.dispatch({
selection: codeMirrorSelection,
})
})
}
return { selectionRangeTypeMap }
}),
},
guards: {
'Selection contains axis': () => true,
'Selection contains edge': () => true,
'Selection contains face': () => true,
'Selection contains line': () => true,
'Selection contains point': () => true,
'Selection is not empty': () => true,
'has valid extrude selection': ({ selectionRanges }) => {
// A user can begin extruding if they either have 1+ faces selected or nothing selected
// TODO: I believe this guard only allows for extruding a single face at a time
if (selectionRanges.codeBasedSelections.length < 1) return false
const isPipe = isSketchPipe(selectionRanges)
if (isSelectionLastLine(selectionRanges, code)) return true
if (!isPipe) return false
return canExtrudeSelection(selectionRanges)
},
'Selection is one face': ({ selectionRanges }) => {
return !!isCursorInSketchCommandRange(
engineCommandManager.artifactMap,
selectionRanges
)
},
},
services: {
'Get horizontal info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } =
await applyConstraintHorzVertDistance({
constraint: 'setHorzDistance',
selectionRanges,
})
await kclManager.updateAst(modifiedAst, true)
return { return {
selectionRangeTypeMap, selectionType: 'completeSelection',
selectionRanges: { selection: pathMapToSelections(
codeBasedSelections: selectionRanges.codeBasedSelections, kclManager.ast,
otherSelections, selectionRanges,
}, pathToNodeMap
),
} }
} },
// This DOES NOT set the `selectionRanges` in xstate context 'Get vertical info': async ({
// same as comment above
const { codeMirrorSelection, selectionRangeTypeMap } =
handleSelectionBatch({
selections: setSelections.selection,
})
if (codeMirrorSelection) {
setTimeout(() => {
editorView.dispatch({
selection: codeMirrorSelection,
})
})
}
return { selectionRangeTypeMap }
}),
},
guards: {
'Selection contains axis': () => true,
'Selection contains edge': () => true,
'Selection contains face': () => true,
'Selection contains line': () => true,
'Selection contains point': () => true,
'Selection is not empty': () => true,
'Selection is one face': ({ selectionRanges }) => {
return !!isCursorInSketchCommandRange(
engineCommandManager.artifactMap,
selectionRanges
)
},
},
services: {
'Get horizontal info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } =
await applyConstraintHorzVertDistance({
constraint: 'setHorzDistance',
selectionRanges,
})
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get vertical info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } =
await applyConstraintHorzVertDistance({
constraint: 'setVertDistance',
selectionRanges,
})
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get angle info': async ({ selectionRanges }): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = await (angleBetweenInfo({
selectionRanges, selectionRanges,
}).enabled }): Promise<SetSelections> => {
? applyConstraintAngleBetween({ const { modifiedAst, pathToNodeMap } =
await applyConstraintHorzVertDistance({
constraint: 'setVertDistance',
selectionRanges, selectionRanges,
}) })
: applyConstraintAngleLength({ await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges, selectionRanges,
angleOrLength: 'setAngle', pathToNodeMap
})) ),
await kclManager.updateAst(modifiedAst, true) }
return { },
selectionType: 'completeSelection', 'Get angle info': async ({
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get length info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = await applyConstraintAngleLength(
{ selectionRanges }
)
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get perpendicular distance info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect({
selectionRanges, selectionRanges,
}) }): Promise<SetSelections> => {
await kclManager.updateAst(modifiedAst, true) const { modifiedAst, pathToNodeMap } = await (angleBetweenInfo({
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get ABS X info': async ({ selectionRanges }): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = await applyConstraintAbsDistance(
{
constraint: 'xAbs',
selectionRanges, selectionRanges,
}).enabled
? applyConstraintAngleBetween({
selectionRanges,
})
: applyConstraintAngleLength({
selectionRanges,
angleOrLength: 'setAngle',
}))
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
} }
) },
await kclManager.updateAst(modifiedAst, true) 'Get length info': async ({
return { selectionRanges,
selectionType: 'completeSelection', }): Promise<SetSelections> => {
selection: pathMapToSelections( const { modifiedAst, pathToNodeMap } =
kclManager.ast, await applyConstraintAngleLength({ selectionRanges })
selectionRanges, await kclManager.updateAst(modifiedAst, true)
pathToNodeMap return {
), selectionType: 'completeSelection',
} selection: pathMapToSelections(
}, kclManager.ast,
'Get ABS Y info': async ({ selectionRanges }): Promise<SetSelections> => { selectionRanges,
const { modifiedAst, pathToNodeMap } = await applyConstraintAbsDistance( pathToNodeMap
{ ),
constraint: 'yAbs',
selectionRanges,
} }
) },
await kclManager.updateAst(modifiedAst, true) 'Get perpendicular distance info': async ({
return { selectionRanges,
selectionType: 'completeSelection', }): Promise<SetSelections> => {
selection: pathMapToSelections( const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect(
kclManager.ast, {
selectionRanges, selectionRanges,
pathToNodeMap }
), )
} await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get ABS X info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } =
await applyConstraintAbsDistance({
constraint: 'xAbs',
selectionRanges,
})
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
'Get ABS Y info': async ({
selectionRanges,
}): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } =
await applyConstraintAbsDistance({
constraint: 'yAbs',
selectionRanges,
})
await kclManager.updateAst(modifiedAst, true)
return {
selectionType: 'completeSelection',
selection: pathMapToSelections(
kclManager.ast,
selectionRanges,
pathToNodeMap
),
}
},
}, },
}, devTools: true,
devTools: true, }
}) )
useEffect(() => { useEffect(() => {
engineCommandManager.onPlaneSelected((plane_id: string) => { engineCommandManager.onPlaneSelected((plane_id: string) => {
@ -538,13 +559,17 @@ export const ModelingMachineProvider = ({
}) })
}, [modelingSend]) }, [modelingSend])
// useStateMachineCommands({ useStateMachineCommands({
// state: settingsState, machineId: 'modeling',
// send: settingsSend, state: modelingState,
// commands, send: modelingSend,
// owner: 'settings', actor: modelingActor,
// commandBarMeta: settingsCommandBarMeta, commandBarConfig: modelingMachineConfig,
// }) onCancel: () => {
console.log('firing onCancel!!')
modelingSend({ type: 'Cancel' })
},
})
return ( return (
<ModelingMachineContext.Provider <ModelingMachineContext.Provider

View File

@ -1,8 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import UserSidebarMenu from './UserSidebarMenu'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import { GlobalStateProvider } from './GlobalStateProvider' import { GlobalStateProvider } from './GlobalStateProvider'
import CommandBarProvider from './CommandBar' import CommandBarProvider from './CommandBar/CommandBar'
import { import {
NETWORK_CONTENT, NETWORK_CONTENT,
NetworkHealthIndicator, NetworkHealthIndicator,

View File

@ -1,8 +1,4 @@
import { import { faExclamation, faWifi } from '@fortawesome/free-solid-svg-icons'
faCheck,
faExclamation,
faWifi,
} from '@fortawesome/free-solid-svg-icons'
import { Popover } from '@headlessui/react' import { Popover } from '@headlessui/react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ActionIcon } from './ActionIcon' import { ActionIcon } from './ActionIcon'
@ -46,7 +42,7 @@ export const NetworkHealthIndicator = () => {
<Popover className="relative"> <Popover className="relative">
<Popover.Button <Popover.Button
className={ className={
'p-0 border-none relative ' + 'p-0 border-none bg-transparent dark:bg-transparent relative ' +
(hasIssues (hasIssues
? 'focus-visible:outline-destroy-80' ? 'focus-visible:outline-destroy-80'
: 'focus-visible:outline-succeed-80') : 'focus-visible:outline-succeed-80')
@ -56,15 +52,17 @@ export const NetworkHealthIndicator = () => {
<span className="sr-only">Network Health</span> <span className="sr-only">Network Health</span>
<ActionIcon <ActionIcon
icon={faWifi} icon={faWifi}
className="p-1"
iconClassName={ iconClassName={
hasIssues hasIssues
? 'text-destroy-80 dark:text-destroy-30' ? 'text-destroy-80 dark:text-destroy-30'
: 'text-succeed-80 dark:text-succeed-30' : 'text-succeed-80 dark:text-succeed-30'
} }
bgClassName={ bgClassName={
hasIssues 'bg-transparent dark:bg-transparent ' +
(hasIssues
? 'hover:bg-destroy-10/50 hover:dark:bg-destroy-80/50 rounded' ? 'hover:bg-destroy-10/50 hover:dark:bg-destroy-80/50 rounded'
: 'hover:bg-succeed-10/50 hover:dark:bg-succeed-80/50 rounded' : 'hover:bg-succeed-10/50 hover:dark:bg-succeed-80/50 rounded')
} }
/> />
</Popover.Button> </Popover.Button>
@ -75,8 +73,8 @@ export const NetworkHealthIndicator = () => {
data-testid="network-good" data-testid="network-good"
> >
<ActionIcon <ActionIcon
icon={faCheck} icon="checkmark"
bgClassName={'bg-succeed-10/50 dark:bg-succeed-80/50 rounded'} bgClassName={'bg-succeed-10/50 dark:bg-succeed-80/50 rounded-sm'}
iconClassName={'text-succeed-80 dark:text-succeed-30'} iconClassName={'text-succeed-80 dark:text-succeed-30'}
/> />
{NETWORK_CONTENT.good} {NETWORK_CONTENT.good}

View File

@ -1,4 +1,4 @@
import { FormEvent, useEffect, useState } from 'react' import { FormEvent, useEffect, useRef, useState } from 'react'
import { type ProjectWithEntryPointMetadata, paths } from '../Router' import { type ProjectWithEntryPointMetadata, paths } from '../Router'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ActionButton } from './ActionButton' import { ActionButton } from './ActionButton'
@ -31,9 +31,11 @@ function ProjectCard({
const [numberOfParts, setNumberOfParts] = useState(1) const [numberOfParts, setNumberOfParts] = useState(1)
const [numberOfFolders, setNumberOfFolders] = useState(0) const [numberOfFolders, setNumberOfFolders] = useState(0)
let inputRef = useRef<HTMLInputElement>(null)
function handleSave(e: FormEvent<HTMLFormElement>) { function handleSave(e: FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
handleRenameProject(e, project).then(() => setIsEditing(false)) void handleRenameProject(e, project).then(() => setIsEditing(false))
} }
function getDisplayedTime(date: Date) { function getDisplayedTime(date: Date) {
@ -52,36 +54,48 @@ function ProjectCard({
setNumberOfParts(kclFileCount) setNumberOfParts(kclFileCount)
setNumberOfFolders(kclDirCount) setNumberOfFolders(kclDirCount)
} }
getNumberOfParts() void getNumberOfParts()
}, [project.path]) }, [project.path])
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [inputRef])
return ( return (
<li <li
{...props} {...props}
className="group relative min-h-[5em] p-1 rounded-sm border border-chalkboard-20 dark:border-chalkboard-90 hover:border-chalkboard-30 dark:hover:border-chalkboard-80" className="group relative min-h-[5em] p-1 rounded-sm border border-chalkboard-20 dark:border-chalkboard-90 hover:border-energy-10 dark:hover:border-chalkboard-70 hover:bg-energy-10/20 dark:hover:bg-chalkboard-90"
> >
{isEditing ? ( {isEditing ? (
<form onSubmit={handleSave} className="flex gap-2 items-center"> <form onSubmit={handleSave} className="flex gap-2 items-center">
<input <input
className="dark:bg-chalkboard-80 dark:border-chalkboard-40 min-w-0 p-1" className="dark:bg-chalkboard-80 dark:border-chalkboard-40 min-w-0 p-1 selection:bg-energy-10/20 focus:outline-none"
type="text" type="text"
id="newProjectName" id="newProjectName"
name="newProjectName" name="newProjectName"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
defaultValue={project.name} defaultValue={project.name}
autoFocus={true} ref={inputRef}
/> />
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
<ActionButton <ActionButton
Element="button" Element="button"
type="submit" type="submit"
icon={{ icon: faCheck, size: 'sm' }} icon={{ icon: faCheck, size: 'sm', className: 'p-1' }}
className="!p-0" className="!p-0"
></ActionButton> ></ActionButton>
<ActionButton <ActionButton
Element="button" Element="button"
icon={{ icon: faX, size: 'sm' }} icon={{
icon: faX,
size: 'sm',
iconClassName: 'dark:!text-chalkboard-20',
className: 'p-1',
}}
className="!p-0" className="!p-0"
onClick={() => setIsEditing(false)} onClick={() => setIsEditing(false)}
/> />
@ -91,8 +105,8 @@ function ProjectCard({
<> <>
<div className="p-1 flex flex-col h-full gap-2"> <div className="p-1 flex flex-col h-full gap-2">
<Link <Link
className="flex-1 text-liquid-100 after:content-[''] after:absolute after:inset-0"
to={`${paths.FILE}/${encodeURIComponent(project.path)}`} to={`${paths.FILE}/${encodeURIComponent(project.path)}`}
className="flex-1 text-liquid-100"
> >
{project.name?.replace(FILE_EXT, '')} {project.name?.replace(FILE_EXT, '')}
</Link> </Link>
@ -106,24 +120,37 @@ function ProjectCard({
<span className="text-chalkboard-60 text-xs"> <span className="text-chalkboard-60 text-xs">
Edited {getDisplayedTime(project.entrypointMetadata.modifiedAt)} Edited {getDisplayedTime(project.entrypointMetadata.modifiedAt)}
</span> </span>
<div className="absolute bottom-2 right-2 flex gap-1 items-center opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"> <div className="absolute z-10 bottom-2 right-2 flex gap-1 items-center opacity-0 group-hover:opacity-100 group-focus-within:opacity-100">
<ActionButton <ActionButton
Element="button" Element="button"
icon={{ icon: faPenAlt, size: 'sm' }} icon={{
onClick={() => setIsEditing(true)} icon: faPenAlt,
className: 'p-1',
iconClassName: 'dark:!text-chalkboard-20',
size: 'xs',
}}
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopPropagation()
setIsEditing(true)
}}
className="!p-0" className="!p-0"
/> />
<ActionButton <ActionButton
Element="button" Element="button"
icon={{ icon={{
icon: faTrashAlt, icon: faTrashAlt,
size: 'sm', className: 'p-1',
bgClassName: 'bg-destroy-80 hover:bg-destroy-70', size: 'xs',
iconClassName: bgClassName: 'bg-destroy-80',
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10 dark:text-destroy-20 dark:group-hover:text-destroy-10 dark:hover:text-destroy-10', iconClassName: '!text-destroy-20 dark:!text-destroy-40',
}} }}
className="!p-0 hover:border-destroy-40 dark:hover:border-destroy-40" className="!p-0 hover:border-destroy-40 dark:hover:border-destroy-40"
onClick={() => setIsConfirmingDelete(true)} onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopPropagation()
setIsConfirmingDelete(true)
}}
/> />
</div> </div>
</div> </div>
@ -156,8 +183,9 @@ function ProjectCard({
icon={{ icon={{
icon: faTrashAlt, icon: faTrashAlt,
bgClassName: 'bg-destroy-80', bgClassName: 'bg-destroy-80',
iconClassName: className: 'p-1',
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10 dark:text-destroy-20 dark:group-hover:text-destroy-10 dark:hover:text-destroy-10', size: 'sm',
iconClassName: '!text-destroy-70 dark:!text-destroy-40',
}} }}
className="hover:border-destroy-40 dark:hover:border-destroy-40" className="hover:border-destroy-40 dark:hover:border-destroy-40"
> >

View File

@ -3,7 +3,7 @@ import { BrowserRouter } from 'react-router-dom'
import ProjectSidebarMenu from './ProjectSidebarMenu' import ProjectSidebarMenu from './ProjectSidebarMenu'
import { ProjectWithEntryPointMetadata } from '../Router' import { ProjectWithEntryPointMetadata } from '../Router'
import { GlobalStateProvider } from './GlobalStateProvider' import { GlobalStateProvider } from './GlobalStateProvider'
import CommandBarProvider from './CommandBar' import CommandBarProvider from './CommandBar/CommandBar'
const now = new Date() const now = new Date()
const projectWellFormed = { const projectWellFormed = {

View File

@ -21,7 +21,7 @@ const ProjectSidebarMenu = ({
return renderAsLink ? ( return renderAsLink ? (
<Link <Link
to={paths.HOME} to={paths.HOME}
className="h-9 max-h-min min-w-max border-0 p-0.5 pr-2 flex items-center gap-4 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-energy-50" className="rounded-sm h-9 mr-auto max-h-min min-w-max border-0 p-0.5 pr-2 flex items-center gap-4 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-energy-50 dark:hover:bg-chalkboard-90"
data-testid="project-sidebar-link" data-testid="project-sidebar-link"
> >
<img <img
@ -39,7 +39,7 @@ const ProjectSidebarMenu = ({
) : ( ) : (
<Popover className="relative"> <Popover className="relative">
<Popover.Button <Popover.Button
className="h-9 max-h-min min-w-max border-0 p-0.5 pr-2 flex items-center gap-4 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-energy-50" className="rounded-sm h-9 mr-auto max-h-min min-w-max border-0 p-0.5 pr-2 flex items-center gap-4 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-energy-50 dark:hover:bg-chalkboard-90"
data-testid="project-sidebar-toggle" data-testid="project-sidebar-toggle"
> >
<img <img
@ -82,12 +82,12 @@ const ProjectSidebarMenu = ({
as={Fragment} as={Fragment}
> >
<Popover.Panel <Popover.Panel
className="fixed inset-0 right-auto z-30 grid w-64 h-screen max-h-screen grid-cols-1 border rounded-r-lg shadow-md bg-chalkboard-10 dark:bg-chalkboard-100 border-energy-100 dark:border-energy-100/50" className="fixed inset-0 right-auto z-30 grid w-64 h-screen max-h-screen grid-cols-1 border rounded-r-md shadow-md bg-chalkboard-10 dark:bg-chalkboard-100 border-chalkboard-40 dark:border-chalkboard-80"
style={{ gridTemplateRows: 'auto 1fr auto' }} style={{ gridTemplateRows: 'auto 1fr auto' }}
> >
{({ close }) => ( {({ close }) => (
<> <>
<div className="flex items-center gap-4 px-4 py-3 bg-energy-10/25 dark:bg-energy-110"> <div className="flex items-center gap-4 px-4 py-3">
<img <img
src="/kitt-8bit-winking.svg" src="/kitt-8bit-winking.svg"
alt="KittyCAD App" alt="KittyCAD App"
@ -115,19 +115,16 @@ const ProjectSidebarMenu = ({
{isTauri() ? ( {isTauri() ? (
<FileTree <FileTree
file={file} file={file}
className="overflow-hidden border-0 border-y border-energy-40 dark:border-energy-70" className="overflow-hidden border-0 border-y border-chalkboard-30 dark:border-chalkboard-80"
closePanel={close} closePanel={close}
/> />
) : ( ) : (
<div className="flex-1 overflow-hidden" /> <div className="flex-1 overflow-hidden" />
)} )}
<div className="flex flex-col gap-2 p-4 bg-energy-10/25 dark:bg-energy-110"> <div className="flex flex-col gap-2 p-4 dark:bg-chalkboard-90">
<ExportButton <ExportButton
className={{ className={{
button: button: 'border-transparent dark:border-transparent',
'border-transparent dark:border-transparent hover:border-energy-60',
icon: 'text-energy-10 dark:text-energy-120',
bg: 'bg-energy-120 dark:bg-energy-10',
}} }}
> >
Export Model Export Model
@ -138,10 +135,10 @@ const ProjectSidebarMenu = ({
to={paths.HOME} to={paths.HOME}
icon={{ icon={{
icon: faHome, icon: faHome,
iconClassName: 'text-energy-10 dark:text-energy-120', className: 'p-1',
bgClassName: 'bg-energy-120 dark:bg-energy-10', size: 'sm',
}} }}
className="border-transparent dark:border-transparent hover:border-energy-60" className="border-transparent dark:border-transparent hover:bg-energy-10/20 dark:hover:bg-chalkboard-90"
> >
Go to Home Go to Home
</ActionButton> </ActionButton>

View File

@ -243,15 +243,14 @@ export const Stream = ({ className = '' }) => {
}) })
} else if ( } else if (
!didDragInStream && !didDragInStream &&
(state.matches('Sketch.SketchIdle') || (state.matches('Sketch.SketchIdle') || state.matches('idle'))
state.matches('idle') ||
state.matches('awaiting selection'))
) { ) {
command.cmd = { command.cmd = {
type: 'select_with_point', type: 'select_with_point',
selected_at_window: { x, y }, selected_at_window: { x, y },
selection_type: 'add', selection_type: 'add',
} }
engineCommandManager.sendSceneCommand(command) engineCommandManager.sendSceneCommand(command)
} else if (!didDragInStream && state.matches('Sketch.Move Tool')) { } else if (!didDragInStream && state.matches('Sketch.Move Tool')) {
command.cmd = { command.cmd = {

View File

@ -64,7 +64,7 @@ export const TextEditor = ({
const { settings: { context: { textWrapping } = {} } = {} } = const { settings: { context: { textWrapping } = {} } = {} } =
useGlobalStateContext() useGlobalStateContext()
const { setCommandBarOpen } = useCommandsContext() const { commandBarSend } = useCommandsContext()
const { enable: convertEnabled, handleClick: convertCallback } = const { enable: convertEnabled, handleClick: convertCallback } =
useConvertToVariable() useConvertToVariable()
@ -136,7 +136,7 @@ export const TextEditor = ({
{ {
key: 'Meta-k', key: 'Meta-k',
run: () => { run: () => {
setCommandBarOpen(true) commandBarSend({ type: 'Open' })
return false return false
}, },
}, },

View File

@ -8,7 +8,7 @@ import {
} from 'react-router-dom' } from 'react-router-dom'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import { GlobalStateProvider } from './GlobalStateProvider' import { GlobalStateProvider } from './GlobalStateProvider'
import CommandBarProvider from './CommandBar' import CommandBarProvider from './CommandBar/CommandBar'
type User = Models['User_type'] type User = Models['User_type']

View File

@ -1,11 +1,6 @@
import { Popover, Transition } from '@headlessui/react' import { Popover, Transition } from '@headlessui/react'
import { ActionButton } from './ActionButton' import { ActionButton } from './ActionButton'
import { import { faBars, faBug, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
faBars,
faBug,
faGear,
faSignOutAlt,
} from '@fortawesome/free-solid-svg-icons'
import { faGithub } from '@fortawesome/free-brands-svg-icons' import { faGithub } from '@fortawesome/free-brands-svg-icons'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { Fragment, useState } from 'react' import { Fragment, useState } from 'react'
@ -43,14 +38,14 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
<Popover className="relative"> <Popover className="relative">
{user?.image && !imageLoadFailed ? ( {user?.image && !imageLoadFailed ? (
<Popover.Button <Popover.Button
className="border-0 rounded-full w-fit min-w-max p-0 focus:outline-none group" className="border-0 rounded-full w-fit min-w-max p-0 group"
data-testid="user-sidebar-toggle" data-testid="user-sidebar-toggle"
> >
<div className="rounded-full border border-chalkboard-70/50 hover:border-liquid-50 group-focus:border-liquid-50 overflow-hidden"> <div className="rounded-full border overflow-hidden">
<img <img
src={user?.image || ''} src={user?.image || ''}
alt={user?.name || ''} alt={user?.name || ''}
className="h-8 w-8" className="h-8 w-8 rounded-full"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onError={() => setImageLoadFailed(true)} onError={() => setImageLoadFailed(true)}
/> />
@ -87,11 +82,11 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
leaveTo="opacity-0 translate-x-4" leaveTo="opacity-0 translate-x-4"
as={Fragment} as={Fragment}
> >
<Popover.Panel className="fixed inset-0 left-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-liquid-100 dark:border-liquid-100/50 shadow-md rounded-l-lg overflow-hidden"> <Popover.Panel className="fixed inset-0 left-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-90 border border-chalkboard-30 dark:border-chalkboard-80 shadow-md rounded-l-md overflow-hidden">
{({ close }) => ( {({ close }) => (
<> <>
{user && ( {user && (
<div className="flex items-center gap-4 px-4 py-3 bg-liquid-100"> <div className="flex items-center gap-4 px-4 py-3 bg-chalkboard-20/50 dark:bg-chalkboard-80/50 border-b border-b-chalkboard-30 dark:border-b-chalkboard-80">
{user.image && !imageLoadFailed && ( {user.image && !imageLoadFailed && (
<div className="rounded-full shadow-inner overflow-hidden"> <div className="rounded-full shadow-inner overflow-hidden">
<img <img
@ -105,15 +100,12 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
)} )}
<div> <div>
<p <p className="m-0 text-mono" data-testid="username">
className="m-0 text-liquid-10 text-mono"
data-testid="username"
>
{displayedName || ''} {displayedName || ''}
</p> </p>
{displayedName !== user.email && ( {displayedName !== user.email && (
<p <p
className="m-0 text-liquid-40 text-xs" className="m-0 text-chalkboard-70 dark:text-chalkboard-40 text-xs"
data-testid="email" data-testid="email"
> >
{user.email} {user.email}
@ -125,8 +117,8 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
<div className="p-4 flex flex-col gap-2"> <div className="p-4 flex flex-col gap-2">
<ActionButton <ActionButton
Element="button" Element="button"
icon={{ icon: faGear }} icon={{ icon: 'gear' }}
className="border-transparent dark:border-transparent dark:hover:border-liquid-60" className="border-transparent dark:border-transparent hover:bg-transparent"
onClick={() => { onClick={() => {
// since /settings is a nested route the sidebar doesn't close // since /settings is a nested route the sidebar doesn't close
// automatically when navigating to it // automatically when navigating to it
@ -142,16 +134,16 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
<ActionButton <ActionButton
Element="externalLink" Element="externalLink"
to="https://github.com/KittyCAD/modeling-app/discussions" to="https://github.com/KittyCAD/modeling-app/discussions"
icon={{ icon: faGithub }} icon={{ icon: faGithub, className: 'p-1', size: 'sm' }}
className="border-transparent dark:border-transparent dark:hover:border-liquid-60" className="border-transparent dark:border-transparent"
> >
Request a feature Request a feature
</ActionButton> </ActionButton>
<ActionButton <ActionButton
Element="externalLink" Element="externalLink"
to="https://github.com/KittyCAD/modeling-app/issues/new" to="https://github.com/KittyCAD/modeling-app/issues/new"
icon={{ icon: faBug }} icon={{ icon: faBug, className: 'p-1', size: 'sm' }}
className="border-transparent dark:border-transparent dark:hover:border-liquid-60" className="border-transparent dark:border-transparent"
> >
Report a bug Report a bug
</ActionButton> </ActionButton>
@ -160,11 +152,13 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
onClick={() => send('Log out')} onClick={() => send('Log out')}
icon={{ icon={{
icon: faSignOutAlt, icon: faSignOutAlt,
className: 'p-1',
bgClassName: 'bg-destroy-80', bgClassName: 'bg-destroy-80',
size: 'sm',
iconClassName: iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10', 'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}} }}
className="border-transparent dark:border-transparent hover:border-destroy-40 dark:hover:border-destroy-60" className="border-transparent dark:border-transparent hover:border-destroy-40 dark:hover:border-destroy-60 hover:bg-destroy-10/20 dark:hover:bg-destroy-80/20"
data-testid="user-sidebar-sign-out" data-testid="user-sidebar-sign-out"
> >
Sign out Sign out

View File

@ -1,7 +1,6 @@
import { Dialog } from '@headlessui/react' import { Dialog } from '@headlessui/react'
import { useState } from 'react' import { useState } from 'react'
import { ActionButton } from './ActionButton' import { ActionButton } from './ActionButton'
import { faX } from '@fortawesome/free-solid-svg-icons'
import { useKclContext } from 'lang/KclSinglton' import { useKclContext } from 'lang/KclSinglton'
export function WasmErrBanner() { export function WasmErrBanner() {
@ -26,7 +25,8 @@ export function WasmErrBanner() {
Element="button" Element="button"
onClick={() => setBannerDismissed(true)} onClick={() => setBannerDismissed(true)}
icon={{ icon={{
icon: faX, icon: 'close',
className: 'p-1',
bgClassName: bgClassName:
'bg-warn-70 hover:bg-warn-80 dark:bg-warn-70 dark:hover:bg-warn-80', 'bg-warn-70 hover:bg-warn-80 dark:bg-warn-70 dark:hover:bg-warn-80',
iconClassName: iconClassName:

View File

@ -1,4 +1,4 @@
import { CommandsContext } from 'components/CommandBar' import { CommandsContext } from 'components/CommandBar/CommandBar'
import { useContext } from 'react' import { useContext } from 'react'
export const useCommandsContext = () => { export const useCommandsContext = () => {

27
src/hooks/usePlatform.ts Normal file
View File

@ -0,0 +1,27 @@
import { Platform, platform } from '@tauri-apps/api/os'
import { isTauri } from 'lib/isTauri'
import { useEffect, useState } from 'react'
export default function usePlatform() {
const [platformName, setPlatformName] = useState<Platform | ''>('')
useEffect(() => {
async function getPlatform() {
setPlatformName(await platform())
}
if (isTauri()) {
void getPlatform()
} else {
if (navigator.userAgent.indexOf('Mac') !== -1) {
setPlatformName('darwin')
} else if (navigator.userAgent.indexOf('Win') !== -1) {
setPlatformName('win32')
} else if (navigator.userAgent.indexOf('Linux') !== -1) {
setPlatformName('linux')
}
}
}, [setPlatformName])
return platformName
}

View File

@ -1,42 +1,68 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { AnyStateMachine, StateFrom } from 'xstate' import { AnyStateMachine, InterpreterFrom, StateFrom } from 'xstate'
import { Command, CommandBarMeta, createMachineCommand } from '../lib/commands' import { createMachineCommand } from '../lib/createMachineCommand'
import { useCommandsContext } from './useCommandsContext' import { useCommandsContext } from './useCommandsContext'
import { modelingMachine } from 'machines/modelingMachine'
import { authMachine } from 'machines/authMachine'
import { settingsMachine } from 'machines/settingsMachine'
import { homeMachine } from 'machines/homeMachine'
import { Command, CommandSetConfig, CommandSetSchema } from 'lib/commandTypes'
interface UseStateMachineCommandsArgs<T extends AnyStateMachine> { // This might not be necessary, AnyStateMachine from xstate is working
export type AllMachines =
| typeof modelingMachine
| typeof settingsMachine
| typeof authMachine
| typeof homeMachine
interface UseStateMachineCommandsArgs<
T extends AllMachines,
S extends CommandSetSchema<T>
> {
machineId: T['id']
state: StateFrom<T> state: StateFrom<T>
send: Function send: Function
commandBarMeta?: CommandBarMeta actor?: InterpreterFrom<T>
commands: Command[] commandBarConfig?: CommandSetConfig<T, S>
owner: string onCancel?: () => void
} }
export default function useStateMachineCommands<T extends AnyStateMachine>({ export default function useStateMachineCommands<
T extends AnyStateMachine,
S extends CommandSetSchema<T>
>({
machineId,
state, state,
send, send,
commandBarMeta, actor,
owner, commandBarConfig,
}: UseStateMachineCommandsArgs<T>) { onCancel,
const { addCommands, removeCommands } = useCommandsContext() }: UseStateMachineCommandsArgs<T, S>) {
const { commandBarSend } = useCommandsContext()
useEffect(() => { useEffect(() => {
const newCommands = state.nextEvents const newCommands = state.nextEvents
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n))) .filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
.map((type) => .map((type) =>
createMachineCommand<T>({ createMachineCommand<T, S>({
ownerMachine: machineId,
type, type,
state, state,
send, send,
commandBarMeta, actor,
owner, commandBarConfig,
onCancel,
}) })
) )
.filter((c) => c !== null) as Command[] .filter((c) => c !== null) as Command[] // TS isn't smart enough to know this filter removes nulls
addCommands(newCommands) commandBarSend({ type: 'Add commands', data: { commands: newCommands } })
return () => { return () => {
removeCommands(newCommands) commandBarSend({
type: 'Remove commands',
data: { commands: newCommands },
})
} }
}, [state]) }, [state])
} }

View File

@ -57,27 +57,43 @@ select {
} }
button { button {
@apply border border-chalkboard-100 m-0.5 px-3 rounded text-xs; @apply border border-chalkboard-30 m-0.5 px-3 rounded text-xs focus-visible:ring-energy-10;
}
button:hover {
@apply border-chalkboard-40 bg-energy-10/20;
} }
.dark button { .dark button {
@apply border-chalkboard-20 hover:border-chalkboard-10 hover:bg-chalkboard-90; @apply border-chalkboard-70 focus-visible:ring-energy-10/50;
}
.dark button:hover {
@apply border-chalkboard-60;
} }
button:disabled { button:disabled {
@apply bg-chalkboard-20 text-chalkboard-60 border-chalkboard-20; @apply cursor-not-allowed bg-chalkboard-20 text-chalkboard-60 border-chalkboard-20;
} }
.dark button:disabled { .dark button:disabled {
@apply bg-chalkboard-90 text-chalkboard-40 border-chalkboard-70; @apply bg-chalkboard-90 text-chalkboard-40 border-chalkboard-70;
} }
a { a:not(.action-button) {
@apply text-liquid-80 hover:text-liquid-70; @apply text-energy-70 hover:text-energy-60;
} }
.dark a { .dark a:not(.action-button) {
@apply text-liquid-20 hover:text-liquid-10; @apply text-chalkboard-20 hover:text-energy-10;
}
input {
@apply selection:bg-energy-10/50;
}
.dark input {
@apply selection:bg-energy-10/40;
} }
.mono { .mono {

View File

@ -4,8 +4,6 @@ import reportWebVitals from './reportWebVitals'
import { Toaster } from 'react-hot-toast' import { Toaster } from 'react-hot-toast'
import { Router } from './Router' import { Router } from './Router'
import { HotkeysProvider } from 'react-hotkeys-hook' import { HotkeysProvider } from 'react-hotkeys-hook'
import { inspect } from '@xstate/inspect'
import { DEV } from 'env'
// uncomment for xstate inspector // uncomment for xstate inspector
// if (DEV) // if (DEV)
@ -19,10 +17,20 @@ root.render(
<HotkeysProvider> <HotkeysProvider>
<Router /> <Router />
<Toaster <Toaster
position="bottom-center" position="top-center"
toastOptions={{ toastOptions={{
style: {
borderRadius: '0.25rem',
},
className: className:
'bg-chalkboard-10 dark:bg-chalkboard-90 text-chalkboard-110 dark:text-chalkboard-10', 'bg-chalkboard-10 dark:bg-chalkboard-90 text-chalkboard-110 dark:text-chalkboard-10 rounded-sm border-chalkboard-20/50 dark:border-chalkboard-80/50',
success: {
iconTheme: {
primary: 'oklch(93.31% 0.227 122.3deg)',
secondary: 'oklch(24.49% 0.01405 158.7deg)',
},
duration: 1500,
},
}} }}
/> />
</HotkeysProvider> </HotkeysProvider>

View File

@ -248,7 +248,8 @@ export function mutateObjExpProp(
export function extrudeSketch( export function extrudeSketch(
node: Program, node: Program,
pathToNode: PathToNode, pathToNode: PathToNode,
shouldPipe = true shouldPipe = true,
distance = 4
): { ): {
modifiedAst: Program modifiedAst: Program
pathToNode: PathToNode pathToNode: PathToNode
@ -274,7 +275,7 @@ export function extrudeSketch(
getNodeFromPath<VariableDeclarator>(_node, pathToNode, 'VariableDeclarator') getNodeFromPath<VariableDeclarator>(_node, pathToNode, 'VariableDeclarator')
const extrudeCall = createCallExpressionStdLib('extrude', [ const extrudeCall = createCallExpressionStdLib('extrude', [
createLiteral(4), createLiteral(distance),
shouldPipe shouldPipe
? createPipeSubstitution() ? createPipeSubstitution()
: { : {

View File

@ -0,0 +1,17 @@
import { CommandSetConfig } from 'lib/commandTypes'
import { authMachine } from 'machines/authMachine'
type AuthCommandSchema = {}
export const authCommandBarConfig: CommandSetConfig<
typeof authMachine,
AuthCommandSchema
> = {
'Log in': {
hide: 'both',
},
'Log out': {
args: [],
icon: 'arrowLeft',
},
}

View File

@ -0,0 +1,87 @@
import { CommandSetConfig } from 'lib/commandTypes'
import { homeMachine } from 'machines/homeMachine'
export type HomeCommandSchema = {
'Create project': {
name: string
}
'Open project': {
name: string
}
'Delete project': {
name: string
}
'Rename project': {
oldName: string
newName: string
}
}
export const homeCommandBarConfig: CommandSetConfig<
typeof homeMachine,
HomeCommandSchema
> = {
'Open project': {
icon: 'arrowRight',
description: 'Open a project',
args: {
name: {
inputType: 'options',
required: true,
options: (context) =>
context.projects.map((p) => ({
name: p.name!,
value: p.name!,
})),
},
},
},
'Create project': {
icon: 'folderPlus',
description: 'Create a project',
args: {
name: {
inputType: 'string',
required: true,
defaultValue: (context) => context.defaultProjectName,
},
},
},
'Delete project': {
icon: 'close',
description: 'Delete a project',
needsReview: true,
args: {
name: {
inputType: 'options',
required: true,
options: (context) =>
context.projects.map((p) => ({
name: p.name!,
value: p.name!,
})),
},
},
},
'Rename project': {
icon: 'folder',
description: 'Rename a project',
needsReview: true,
args: {
oldName: {
inputType: 'options',
required: true,
options: (context) =>
context.projects.map((p) => ({
name: p.name!,
value: p.name!,
})),
},
newName: {
inputType: 'string',
required: true,
defaultValue: (context) => context.defaultProjectName,
},
},
},
}

View File

@ -0,0 +1,62 @@
import { CommandSetConfig } from 'lib/commandTypes'
import { Selections } from 'lib/selections'
import { modelingMachine } from 'machines/modelingMachine'
export const EXTRUSION_RESULTS = [
'new',
'add',
'subtract',
'intersect',
] as const
export type ModelingCommandSchema = {
'Enter sketch': {}
Extrude: {
selection: Selections // & { type: 'face' } would be cool to lock that down
// result: (typeof EXTRUSION_RESULTS)[number]
distance: number
makeVariable?: string
}
}
export const modelingMachineConfig: CommandSetConfig<
typeof modelingMachine,
ModelingCommandSchema
> = {
'Enter sketch': {
description: 'Enter sketch mode.',
icon: 'sketch',
},
Extrude: {
description: 'Pull a sketch into 3D along its normal or perpendicular.',
icon: 'extrude',
needsReview: true,
args: {
selection: {
inputType: 'selection',
selectionTypes: ['face'],
multiple: false, // TODO: multiple selection
required: true,
},
// result: {
// inputType: 'options',
// payload: 'add',
// required: true,
// options: EXTRUSION_RESULTS.map((r) => ({
// name: r,
// isCurrent: r === 'add',
// value: r,
// })),
// },
distance: {
inputType: 'number',
defaultValue: 5,
required: true,
},
makeVariable: {
inputType: 'string',
required: false,
},
},
},
}

View File

@ -0,0 +1,141 @@
import { CommandSetConfig } from '../commandTypes'
import {
BaseUnit,
Toggle,
UnitSystem,
baseUnitsUnion,
settingsMachine,
} from 'machines/settingsMachine'
import { CameraSystem, cameraSystems } from '../cameraControls'
import { Themes } from '../theme'
// SETTINGS MACHINE
export type SettingsCommandSchema = {
'Set Base Unit': {
baseUnit: BaseUnit
}
'Set Camera Controls': {
cameraControls: CameraSystem
}
'Set Default Project Name': {
defaultProjectName: string
}
'Set Text Wrapping': {
textWrapping: Toggle
}
'Set Theme': {
theme: Themes
}
'Set Unit System': {
unitSystem: UnitSystem
}
}
export const settingsCommandBarConfig: CommandSetConfig<
typeof settingsMachine,
SettingsCommandSchema
> = {
'Set Base Unit': {
icon: 'gear',
args: {
baseUnit: {
inputType: 'options',
required: true,
defaultValue: (context) => context.baseUnit,
options: (context) =>
Object.values(baseUnitsUnion).map((v) => ({
name: v,
value: v,
isCurrent: v === context.baseUnit,
})),
},
},
},
'Set Camera Controls': {
icon: 'gear',
args: {
cameraControls: {
inputType: 'options',
required: true,
defaultValue: (context) => context.cameraControls,
options: (context) =>
Object.values(cameraSystems).map((v) => ({
name: v,
value: v,
isCurrent: v === context.cameraControls,
})),
},
},
},
'Set Default Project Name': {
icon: 'gear',
hide: 'web',
args: {
defaultProjectName: {
inputType: 'string',
required: true,
defaultValue: (context) => context.defaultProjectName,
},
},
},
'Set Text Wrapping': {
icon: 'gear',
args: {
textWrapping: {
inputType: 'options',
required: true,
defaultValue: (context) => context.textWrapping,
options: (context) => [
{
name: 'On',
value: 'On' as Toggle,
isCurrent: context.textWrapping === 'On',
},
{
name: 'Off',
value: 'Off' as Toggle,
isCurrent: context.textWrapping === 'Off',
},
],
},
},
},
'Set Theme': {
icon: 'gear',
args: {
theme: {
inputType: 'options',
required: true,
defaultValue: (context) => context.theme,
options: (context) =>
Object.values(Themes).map((v) => ({
name: v,
value: v,
isCurrent: v === context.theme,
})),
},
},
},
'Set Unit System': {
icon: 'gear',
args: {
unitSystem: {
inputType: 'options',
required: true,
defaultValue: (context) => context.unitSystem,
options: (context) => [
{
name: 'Imperial',
value: 'imperial' as UnitSystem,
isCurrent: context.unitSystem === 'imperial',
},
{
name: 'Metric',
value: 'metric' as UnitSystem,
isCurrent: context.unitSystem === 'metric',
},
],
},
},
},
}

136
src/lib/commandTypes.ts Normal file
View File

@ -0,0 +1,136 @@
import { CustomIconName } from 'components/CustomIcon'
import { AllMachines } from 'hooks/useStateMachineCommands'
import {
AnyStateMachine,
ContextFrom,
EventFrom,
InterpreterFrom,
} from 'xstate'
import { Selection } from './selections'
type Icon = CustomIconName
const PLATFORMS = ['both', 'web', 'desktop'] as const
const INPUT_TYPES = ['options', 'string', 'number', 'selection'] as const
export type CommandInputType = (typeof INPUT_TYPES)[number]
export type CommandSetSchema<T extends AnyStateMachine> = Partial<{
[EventType in EventFrom<T>['type']]: Record<string, any>
}>
export type CommandSet<
T extends AllMachines,
Schema extends CommandSetSchema<T>
> = Partial<{
[EventType in EventFrom<T>['type']]: Command<
T,
EventFrom<T>['type'],
Schema[EventType]
>
}>
export type CommandSetConfig<
T extends AllMachines,
Schema extends CommandSetSchema<T>
> = Partial<{
[EventType in EventFrom<T>['type']]: CommandConfig<
T,
EventFrom<T>['type'],
Schema[EventType]
>
}>
export type Command<
T extends AnyStateMachine = AnyStateMachine,
CommandName extends EventFrom<T>['type'] = EventFrom<T>['type'],
CommandSchema extends CommandSetSchema<T>[CommandName] = CommandSetSchema<T>[CommandName]
> = {
name: CommandName
ownerMachine: T['id']
needsReview: boolean
onSubmit: (data?: CommandSchema) => void
onCancel?: () => void
args?: {
[ArgName in keyof CommandSchema]: CommandArgument<CommandSchema[ArgName], T>
}
description?: string
icon?: Icon
hide?: (typeof PLATFORMS)[number]
}
export type CommandConfig<
T extends AnyStateMachine = AnyStateMachine,
CommandName extends EventFrom<T>['type'] = EventFrom<T>['type'],
CommandSchema extends CommandSetSchema<T>[CommandName] = CommandSetSchema<T>[CommandName]
> = Omit<
Command<T, CommandName, CommandSchema>,
'name' | 'ownerMachine' | 'onSubmit' | 'onCancel' | 'args' | 'needsReview'
> & {
needsReview?: true
args?: {
[ArgName in keyof CommandSchema]: CommandArgumentConfig<
CommandSchema[ArgName],
T
>
}
}
export type CommandArgumentConfig<
OutputType,
T extends AnyStateMachine = AnyStateMachine
> =
| {
description?: string
required: boolean
skip?: true
defaultValue?: OutputType | ((context: ContextFrom<T>) => OutputType)
payload?: OutputType
} & (
| {
inputType: Extract<CommandInputType, 'options'>
options:
| CommandArgumentOption<OutputType>[]
| ((context: ContextFrom<T>) => CommandArgumentOption<OutputType>[])
}
| {
inputType: Extract<CommandInputType, 'selection'>
selectionTypes: Selection['type'][]
multiple: boolean
}
| { inputType: Exclude<CommandInputType, 'options' | 'selection'> }
)
export type CommandArgument<
OutputType,
T extends AnyStateMachine = AnyStateMachine
> =
| {
description?: string
required: boolean
payload?: OutputType // Payload sets the initialized value and more importantly its type
defaultValue?: OutputType // Default value is used as the starting value for the input on this argument
} & (
| {
inputType: Extract<CommandInputType, 'options'>
options: CommandArgumentOption<OutputType>[]
}
| {
inputType: Extract<CommandInputType, 'selection'>
selectionTypes: Selection['type'][]
actor: InterpreterFrom<T>
multiple: boolean
}
| { inputType: Exclude<CommandInputType, 'options' | 'selection'> }
)
export type CommandArgumentWithName<
OutputType,
T extends AnyStateMachine = AnyStateMachine
> = CommandArgument<OutputType, T> & {
name: string
}
export type CommandArgumentOption<A> = {
name: string
isCurrent?: boolean
value: A
}

View File

@ -1,124 +0,0 @@
import { AnyStateMachine, EventFrom, StateFrom } from 'xstate'
import { isTauri } from './isTauri'
type InitialCommandBarMetaArg = {
name: string
type: 'string' | 'select'
description?: string
defaultValue?: string
options: string | Array<{ name: string }>
}
type Platform = 'both' | 'web' | 'desktop'
export type CommandBarMeta = {
[key: string]:
| {
displayValue: (args: string[]) => string
args: InitialCommandBarMetaArg[]
hide?: Platform
}
| {
hide?: Platform
}
}
export type Command = {
owner: string
name: string
callback: Function
meta?: {
displayValue(args: string[]): string | string
args: SubCommand[]
}
}
export type SubCommand = {
name: string
type: 'select' | 'string'
description?: string
options?: Partial<{ name: string }>[]
}
interface CommandBarArgs<T extends AnyStateMachine> {
type: EventFrom<T>['type']
state: StateFrom<T>
commandBarMeta?: CommandBarMeta
send: Function
owner: string
}
export function createMachineCommand<T extends AnyStateMachine>({
type,
state,
commandBarMeta,
send,
owner,
}: CommandBarArgs<T>): Command | null {
const lookedUpMeta = commandBarMeta && commandBarMeta[type]
if (lookedUpMeta && 'hide' in lookedUpMeta) {
const { hide } = lookedUpMeta
if (hide === 'both') return null
else if (hide === 'desktop' && isTauri()) return null
else if (hide === 'web' && !isTauri()) return null
}
let replacedArgs
if (lookedUpMeta && 'args' in lookedUpMeta) {
replacedArgs = lookedUpMeta.args.map((arg) => {
const optionsFromContext = state.context[
arg.options as keyof typeof state.context
] as { name: string }[] | string | undefined
const defaultValueFromContext = state.context[
arg.defaultValue as keyof typeof state.context
] as string | undefined
const options =
arg.options instanceof Array
? arg.options.map((o) => ({
...o,
description:
defaultValueFromContext === o.name ? '(current)' : '',
}))
: !optionsFromContext || typeof optionsFromContext === 'string'
? [
{
name: optionsFromContext,
description: arg.description || '',
},
]
: optionsFromContext.map((o) => ({
name: o.name || '',
description: arg.description || '',
}))
return {
...arg,
options,
}
}) as any[]
}
// We have to recreate this object every time,
// otherwise we'll have stale state in the CommandBar
// after completing our first action
const meta = lookedUpMeta
? {
...lookedUpMeta,
args: replacedArgs,
}
: undefined
return {
name: type,
owner,
callback: (data: EventFrom<T, typeof type>) => {
if (data !== undefined && data !== null) {
send(type, { data })
} else {
send(type)
}
},
meta: meta as any,
}
}

View File

@ -0,0 +1,158 @@
import { AnyStateMachine, EventFrom, InterpreterFrom, StateFrom } from 'xstate'
import { isTauri } from './isTauri'
import {
Command,
CommandArgument,
CommandArgumentConfig,
CommandConfig,
CommandSetConfig,
CommandSetSchema,
} from './commandTypes'
interface CreateMachineCommandProps<
T extends AnyStateMachine,
S extends CommandSetSchema<T>
> {
type: EventFrom<T>['type']
ownerMachine: T['id']
state: StateFrom<T>
send: Function
actor?: InterpreterFrom<T>
commandBarConfig?: CommandSetConfig<T, S>
onCancel?: () => void
}
// Creates a command with subcommands, ready for use in the CommandBar component,
// from a more terse Command Bar Meta definition.
export function createMachineCommand<
T extends AnyStateMachine,
S extends CommandSetSchema<T>
>({
ownerMachine,
type,
state,
send,
actor,
commandBarConfig,
onCancel,
}: CreateMachineCommandProps<T, S>): Command<
T,
typeof type,
S[typeof type]
> | null {
const commandConfig = commandBarConfig && commandBarConfig[type]
if (!commandConfig) return null
// Hide commands based on platform by returning `null`
// so the consumer can filter them out
if ('hide' in commandConfig) {
const { hide } = commandConfig
if (hide === 'both') return null
else if (hide === 'desktop' && isTauri()) return null
else if (hide === 'web' && !isTauri()) return null
}
const icon = ('icon' in commandConfig && commandConfig.icon) || undefined
const command: Command<T, typeof type, S[typeof type]> = {
name: type,
ownerMachine: ownerMachine,
icon,
needsReview: commandConfig.needsReview || false,
onSubmit: (data?: S[typeof type]) => {
if (data !== undefined && data !== null) {
send(type, { data })
} else {
send(type)
}
},
}
if (commandConfig.args) {
const newArgs = buildCommandArguments(state, commandConfig.args, actor)
command.args = newArgs
}
if (onCancel) {
command.onCancel = onCancel
}
return command
}
// Takes the args from a CommandConfig and creates
// a finalized CommandArgument object for each one,
// bundled together into the args for a Command.
function buildCommandArguments<
T extends AnyStateMachine,
S extends CommandSetSchema<T>,
CommandName extends EventFrom<T>['type'] = EventFrom<T>['type']
>(
state: StateFrom<T>,
args: CommandConfig<T, CommandName, S>['args'],
actor?: InterpreterFrom<T>
): NonNullable<Command<T, CommandName, S>['args']> {
const newArgs = {} as NonNullable<Command<T, CommandName, S>['args']>
for (const arg in args) {
const argConfig = args[arg] as CommandArgumentConfig<S[typeof arg], T>
const newArg = buildCommandArgument(argConfig, state, actor)
newArgs[arg] = newArg
}
return newArgs
}
function buildCommandArgument<
O extends CommandSetSchema<T>,
T extends AnyStateMachine
>(
arg: CommandArgumentConfig<O, T>,
state: StateFrom<T>,
actor?: InterpreterFrom<T>
): CommandArgument<O, T> & { inputType: typeof arg.inputType } {
const baseCommandArgument = {
description: arg.description,
required: arg.required,
payload: arg.payload,
defaultValue:
arg.defaultValue instanceof Function
? arg.defaultValue(state.context)
: arg.defaultValue,
} satisfies Omit<CommandArgument<O, T>, 'inputType'>
if (arg.inputType === 'options') {
const options = arg.options
? arg.options instanceof Function
? arg.options(state.context)
: arg.options
: undefined
if (!options) {
throw new Error('Options must be provided for options input type')
}
return {
inputType: arg.inputType,
...baseCommandArgument,
options,
} satisfies CommandArgument<O, T> & { inputType: 'options' }
} else if (arg.inputType === 'selection') {
if (!actor)
throw new Error('Actor must be provided for selection input type')
return {
inputType: arg.inputType,
...baseCommandArgument,
multiple: arg.multiple,
selectionTypes: arg.selectionTypes,
actor,
} satisfies CommandArgument<O, T> & { inputType: 'selection' }
} else {
return {
inputType: arg.inputType,
...baseCommandArgument,
}
}
}

View File

@ -7,6 +7,10 @@ import { EditorSelection } from '@codemirror/state'
import { kclManager } from 'lang/KclSinglton' import { kclManager } from 'lang/KclSinglton'
import { SelectionRange } from '@uiw/react-codemirror' import { SelectionRange } from '@uiw/react-codemirror'
import { isOverlap } from 'lib/utils' import { isOverlap } from 'lib/utils'
import { isCursorInSketchCommandRange } from 'lang/util'
import { Program } from 'lang/wasm'
import { doesPipeHaveCallExp } from 'lang/queryAst'
import { CommandArgument } from './commandTypes'
export const X_AXIS_UUID = 'ad792545-7fd3-482a-a602-a93924e3055b' export const X_AXIS_UUID = 'ad792545-7fd3-482a-a602-a93924e3055b'
export const Y_AXIS_UUID = '680fd157-266f-4b8a-984f-cdf46b8bdf01' export const Y_AXIS_UUID = '680fd157-266f-4b8a-984f-cdf46b8bdf01'
@ -371,3 +375,128 @@ function resetAndSetEngineEntitySelectionCmds(
}, },
] ]
} }
export function isSketchPipe(selectionRanges: Selections) {
return isCursorInSketchCommandRange(
engineCommandManager.artifactMap,
selectionRanges
)
}
export function isSelectionLastLine(
selectionRanges: Selections,
code: string,
i = 0
) {
return selectionRanges.codeBasedSelections[i].range[1] === code.length
}
export type CommonASTNode = {
selection: Selection
ast: Program
}
export function buildCommonNodeFromSelection(
selectionRanges: Selections,
i: number
) {
return {
selection: selectionRanges.codeBasedSelections[i],
ast: kclManager.ast,
}
}
export function nodeHasExtrude(node: CommonASTNode) {
return doesPipeHaveCallExp({
calleeName: 'extrude',
...node,
})
}
export function nodeHasClose(node: CommonASTNode) {
return doesPipeHaveCallExp({
calleeName: 'close',
...node,
})
}
export function canExtrudeSelection(selection: Selections) {
const commonNodes = selection.codeBasedSelections.map((_, i) =>
buildCommonNodeFromSelection(selection, i)
)
return (
!!isSketchPipe(selection) &&
commonNodes.every((n) => nodeHasClose(n)) &&
commonNodes.every((n) => !nodeHasExtrude(n))
)
}
export function canExtrudeSelectionItem(selection: Selections, i: number) {
const commonNode = buildCommonNodeFromSelection(selection, i)
return (
!!isSketchPipe(selection) &&
nodeHasClose(commonNode) &&
!nodeHasExtrude(commonNode)
)
}
// This accounts for non-geometry selections under "other"
export type ResolvedSelectionType = [Selection['type'] | 'other', number]
/**
* In the future, I'd like this function to properly return the type of each selected entity based on
* its code source range, so that we can show something like "0 objects" or "1 face" or "1 line, 2 edges",
* and then validate the selection in CommandBarSelectionInput.tsx and show the proper label.
* @param selection
* @returns
*/
export function getSelectionType(
selection: Selections
): ResolvedSelectionType[] {
return selection.codeBasedSelections
.map((s, i) => {
if (canExtrudeSelectionItem(selection, i)) {
return ['face', 1] as ResolvedSelectionType // This is implicitly determining what a face is, which is bad
} else {
return ['other', 1] as ResolvedSelectionType
}
})
.reduce((acc, [type, count]) => {
const foundIndex = acc.findIndex((item) => item && item[0] === type)
if (foundIndex === -1) {
return [...acc, [type, count]]
} else {
const temp = [...acc]
temp[foundIndex][1] += count
return temp
}
}, [] as ResolvedSelectionType[])
}
export function getSelectionTypeDisplayText(
selection: Selections
): string | null {
const selectionsByType = getSelectionType(selection)
return (selectionsByType as Exclude<typeof selectionsByType, 'none'>)
.map(([type, count]) => `${count} ${type}${count > 1 ? 's' : ''}`)
.join(', ')
}
export function canSubmitSelectionArg(
selectionsByType: 'none' | ResolvedSelectionType[],
argument: CommandArgument<unknown> & { inputType: 'selection' }
) {
return (
selectionsByType !== 'none' &&
selectionsByType.every(([type, count]) => {
const foundIndex = argument.selectionTypes.findIndex((s) => s === type)
return (
foundIndex !== -1 &&
(!argument.multiple ? count < 2 && count > 0 : count > 0)
)
})
)
}

View File

@ -1,7 +1,7 @@
import { import {
faArrowDown, faArrowDown,
faArrowUp, faArrowUp,
faCircleDot, faCircle,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { ProjectWithEntryPointMetadata } from '../Router' import { ProjectWithEntryPointMetadata } from '../Router'
@ -13,7 +13,7 @@ export function getSortIcon(currentSort: string, newSort: string) {
} else if (currentSort === newSort + DESC) { } else if (currentSort === newSort + DESC) {
return faArrowDown return faArrowDown
} }
return faCircleDot return faCircle
} }
export function getNextSearchParams(currentSort: string, newSort: string) { export function getNextSearchParams(currentSort: string, newSort: string) {

View File

@ -1,7 +1,6 @@
import { createMachine, assign } from 'xstate' import { createMachine, assign } from 'xstate'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import withBaseURL from '../lib/withBaseURL' import withBaseURL from '../lib/withBaseURL'
import { CommandBarMeta } from '../lib/commands'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { invoke } from '@tauri-apps/api' import { invoke } from '@tauri-apps/api'
import { VITE_KC_API_BASE_URL } from 'env' import { VITE_KC_API_BASE_URL } from 'env'
@ -40,12 +39,6 @@ export type Events =
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY' export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || '' const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
export const authCommandBarMeta: CommandBarMeta = {
'Log in': {
hide: 'both',
},
}
export const authMachine = createMachine<UserContext, Events>( export const authMachine = createMachine<UserContext, Events>(
{ {
id: 'Auth', id: 'Auth',

View File

@ -0,0 +1,447 @@
import { assign, createMachine } from 'xstate'
import {
Command,
CommandArgument,
CommandArgumentWithName,
} from 'lib/commandTypes'
import { Selections } from 'lib/selections'
export const commandBarMachine = createMachine(
{
/** @xstate-layout N4IgpgJg5mDOIC5QGED2BbdBDAdhABAEJYBOAxMgDaqxgDaADALqKgAONAlgC6eo6sQAD0QBaAJwA6AGwAmAKwBmBoukAWafIAcDcSoA0IAJ6JZDaZIDs8hgzV6AjA61a1DWQF8PhtJlwFiciowUkYWJBAOWB4+AQiRBFF5CwdpcVkHS1lpVyU5QxNEh1lFGTUsrUUtOQd5SwZLLx8MbDwiUkkqGkgyAHk2MBwwwSiY-kEEswdJNWcNbPFLNMr5AsRFWUtJcVSdRR2VVMUmkF9WgI6u2ggyADFONv98WkowAGNufDeW-2GI0d443iiHESkktUUG1klTsSi0awQDgYWhmqkWjnUslBWhOZyegU61GuZAAghACN8-HhYH92FxAXFQAlRA5FpJ5FjFPYtNDpIpLOkEW4GNs4eJxJoGvJxOVcT82gSrj0AEpgdCoABuYC+8ogNOYI3psQmYlkGUkU2slhc6mkmUUCIyKLc4i0lkU8iUyjUHLlVIuJEkAGUwK8Pg8oDr-WQQ2HPpTzrTIkagUzEDkLHZkQK1CUtKCHAiNlsdjkVAdFEc-ed2oG8W0Xu9uD0kwDjcCEJDplUPfn+Ut7CUhXJJFo6uYbBOMtJq-jLrrnqGmy2HOE6dEGSbESoRSUHOVqpV99Zh7JR+PXPu1G7zI1vKcFwSAOJYbgACzAJAj+FIUAAruggzcLAFBvrgMBfH+JAkEBP4kP+gE4NwrYpoywiIA4qjyDIyR2LmlTSHY8LGBhyjsrm-L2No0hpHys4Kh0L7vp+36-gBQEgQAInAS4fFGiYGv8qFbjkpSWLmswMNK-LOI6UmSKouZOBo8jOPu9EBpITEfl+OCRmxiHAZIJIAO5YDEen4A8bB-twMZ-gARugPBwQhQEoRu7ZpoibilJU1SVnaRFqA6JEIAe4IevIua2BKLhqBptZaa+OmsfB7FIbAkgAGpYJQnAQK+EZkBA-BgJIDwaqgADW5UanlBWvmAJLpYZHljGhCSZOUMxmjK2Q6FJ+4Iny3bujYmyqVkxz3vWmnaSxlkGRxOUNYVvB6WQn4kKggZsJQr4AGa7egkj1fl63Na17mCeuHVbvuhEKTyokuBOWRCpsVhZEsE02LkiUEqqGqcGAJn2U5LkJr8t3Jp5qboQgljTNoUnSpsb0uKFhTuiK6jpPUyi6C6gMdMDoPg2SBDLUh7Wbh24qlNCAovWabj5GFAqlBoGZIqkTh2qTgbk2DZCquqWquRlyGw22CNdQwrJlGkehuq4eYjVyo4+jy3WVg0OKzY+ZNgCDosAKIQC5NMy2ucP3R2mjYTY2gSp6rKgpraja3U4qzDYkLiELnQfm81Xfi1bmZSVZUVTgVW1Wda1NZH0v6nbcudYg0Uo1JJTKE49SyWFegWCFMqYToZg5J4Rv+klyCh+Hlmp4ZIGlTg5WVTVdXJ82rccXQq6GvDWcIFi2x2qCizWC40XlAiLhnskejuO6NGbEHdc1oqTcR9d0fbbtkj7UdJ1JxdKcH8BdNeYjx5bPsORjukqmwiNGgzPUwriiU7hb80euz4UqLX0tfEC4tNTahtrfeWpg7DTAlMeQ8XIOSrDCrsdk4gqjImRD6TCIVg4LV0mAqOwExZqigVLNqw8hKjwepoUo7g4SzE0EFQsGDpTbFcG6AhgUlheHvDgVAEA4CCDmrWEeDtvKiAWBaRWVobQaHtI6aYylzDFGUIpSsCVt5zjrESSAUj6YyOKBaPM1pUj1E9NKdBhQPTTBCq4SsGx9hs2DrGJs35oZ4GMXfBIswmabG9FJZGdRZBFlUqiZxmESjpFkLowBO95z10bB8IxdDpGIxntrZE2QsTSGsBw+xX1tDXkwioM0-JQREJASQ6hHE-FwMRAkqQUUiZ5KkuoIslZ2QuBlFaGiWQEm1OYvUm2WVTLmQ2pGaytkmlj2KGydpthOnJDUEKDYkh3CpBovjSomxRmpSWuA1al8ZkLIYUscEEo1AunsJYd0lghTOG2E4aoeRgniUSQ+IBJszYmUuY7TI4I7n9lUHIdIxEcZRPKFFbI0UMyGySfokO7xm6RgHplIF3laivMZgRNm7NsaIACgpV2SxajYMhDNLwQA */
context: {
commands: [] as Command[],
selectedCommand: undefined as Command | undefined,
currentArgument: undefined as
| (CommandArgument<unknown> & { name: string })
| undefined,
selectionRanges: {
otherSelections: [],
codeBasedSelections: [],
} as Selections,
argumentsToSubmit: {} as { [x: string]: unknown },
},
id: 'Command Bar',
initial: 'Closed',
states: {
Closed: {
on: {
Open: {
target: 'Selecting command',
},
'Find and select command': {
target: 'Command selected',
actions: [
'Find and select command',
'Initialize arguments to submit',
],
},
'Add commands': {
target: 'Closed',
actions: [
assign({
commands: (context, event) =>
[...context.commands, ...event.data.commands].sort(
sortCommands
),
}),
],
internal: true,
},
'Remove commands': {
target: 'Closed',
actions: [
assign({
commands: (context, event) =>
context.commands.filter(
(c) =>
!event.data.commands.some(
(c2) =>
c2.name === c.name &&
c2.ownerMachine === c.ownerMachine
)
),
}),
],
internal: true,
},
},
},
'Selecting command': {
on: {
'Select command': {
target: 'Command selected',
actions: ['Set selected command', 'Initialize arguments to submit'],
},
},
},
'Command selected': {
always: [
{
target: 'Closed',
cond: 'Command has no arguments',
actions: ['Execute command'],
},
{
target: 'Gathering arguments',
actions: [
assign({
currentArgument: (context, event) => {
const { selectedCommand } = context
if (!(selectedCommand && selectedCommand.args))
return undefined
const argName = Object.keys(selectedCommand.args)[0]
return {
...selectedCommand.args[argName],
name: argName,
}
},
}),
],
},
],
},
'Gathering arguments': {
states: {
'Awaiting input': {
on: {
'Submit argument': {
target: 'Validating',
},
},
},
Validating: {
invoke: {
src: 'Validate argument',
id: 'validateArgument',
onDone: {
target: '#Command Bar.Checking Arguments',
actions: [
assign({
argumentsToSubmit: (context, event) => {
const [argName, argData] = Object.entries(event.data)[0]
const { currentArgument } = context
if (!currentArgument) return {}
return {
...context.argumentsToSubmit,
[argName]: argData,
}
},
}),
],
},
onError: [
{
target: 'Awaiting input',
},
],
},
},
},
initial: 'Awaiting input',
on: {
'Change current argument': {
target: 'Gathering arguments',
internal: true,
actions: ['Set current argument'],
},
'Deselect command': {
target: 'Selecting command',
actions: [
assign({
selectedCommand: (_c, _e) => undefined,
}),
],
},
'Remove argument': [
{
target: 'Review',
cond: 'Is current argument',
actions: 'Remove argument',
},
{
target: 'Gathering arguments',
internal: true,
actions: 'Remove argument',
},
],
},
},
Review: {
entry: ['Clear current argument'],
on: {
'Submit command': {
target: 'Closed',
actions: ['Execute command'],
},
'Add argument': {
target: 'Gathering arguments',
actions: ['Set current argument'],
},
'Remove argument': {
target: 'Review',
actions: ['Remove argument'],
},
'Edit argument': {
target: 'Gathering arguments',
actions: ['Set current argument'],
},
},
},
'Checking Arguments': {
invoke: {
src: 'Validate all arguments',
id: 'validateArguments',
onDone: [
{
target: 'Review',
cond: 'Command needs review',
},
{
target: 'Closed',
actions: 'Execute command',
},
],
onError: [
{
target: 'Gathering arguments',
actions: ['Set current argument'],
},
],
},
},
},
on: {
Close: {
target: '.Closed',
},
Clear: {
target: '#Command Bar',
internal: true,
actions: ['Clear argument data'],
},
},
schema: {
events: {} as
| { type: 'Open' }
| { type: 'Close' }
| { type: 'Clear' }
| {
type: 'Select command'
data: { command: Command }
}
| { type: 'Deselect command' }
| { type: 'Submit command'; data: { [x: string]: unknown } }
| {
type: 'Add argument'
data: { argument: CommandArgumentWithName<unknown> }
}
| {
type: 'Remove argument'
data: { [x: string]: CommandArgumentWithName<unknown> }
}
| {
type: 'Edit argument'
data: { arg: CommandArgumentWithName<unknown> }
}
| {
type: 'Add commands'
data: { commands: Command[] }
}
| {
type: 'Remove commands'
data: { commands: Command[] }
}
| { type: 'Submit argument'; data: { [x: string]: unknown } }
| {
type: 'done.invoke.validateArguments'
data: { [x: string]: unknown }
}
| {
type: 'error.platform.validateArguments'
data: { message: string; arg: CommandArgumentWithName<unknown> }
}
| {
type: 'Find and select command'
data: { name: string; ownerMachine: string }
}
| {
type: 'Change current argument'
data: { arg: CommandArgumentWithName<unknown> }
},
},
predictableActionArguments: true,
preserveActionOrder: true,
},
{
actions: {
'Execute command': (context, event) => {
const { selectedCommand } = context
if (!selectedCommand) return
if (selectedCommand?.args) {
selectedCommand?.onSubmit(
event.type === 'Submit command' ||
event.type === 'done.invoke.validateArguments'
? event.data
: undefined
)
} else {
selectedCommand?.onSubmit()
}
},
'Clear current argument': assign({
currentArgument: undefined,
}),
'Set current argument': assign({
currentArgument: (context, event) => {
switch (event.type) {
case 'error.platform.validateArguments':
return event.data.arg
case 'Edit argument':
return event.data.arg
case 'Change current argument':
return event.data.arg
default:
return context.currentArgument
}
},
}),
'Clear argument data': assign({
selectedCommand: undefined,
currentArgument: undefined,
argumentsToSubmit: {},
}),
'Set selected command': assign({
selectedCommand: (c, e) =>
e.type === 'Select command' ? e.data.command : c.selectedCommand,
}),
'Find and select command': assign({
selectedCommand: (c, e) => {
if (e.type !== 'Find and select command') return c.selectedCommand
const found = c.commands.find(
(cmd) =>
cmd.name === e.data.name &&
cmd.ownerMachine === e.data.ownerMachine
)
return !!found ? found : c.selectedCommand
},
}),
'Initialize arguments to submit': assign({
argumentsToSubmit: (c, e) => {
if (
e.type !== 'Select command' &&
e.type !== 'Find and select command'
)
return c.argumentsToSubmit
const command =
'command' in e.data ? e.data.command : c.selectedCommand!
if (!command.args) return {}
const args: { [x: string]: unknown } = {}
for (const [argName, arg] of Object.entries(command.args)) {
args[argName] = arg.payload
}
return args
},
}),
'Remove argument': assign({
argumentsToSubmit: (context, event) => {
if (event.type !== 'Remove argument') return context.argumentsToSubmit
const argName = Object.keys(event.data)[0]
const { argumentsToSubmit } = context
const newArgumentsToSubmit = { ...argumentsToSubmit }
delete newArgumentsToSubmit[argName]
return newArgumentsToSubmit
},
}),
},
guards: {
'Command needs review': (context, _) =>
context.selectedCommand?.needsReview || false,
'Is current argument': (context, event) => {
if (event.type !== 'Remove argument') return false
const argName = Object.keys(event.data)[0]
return argName === context.currentArgument?.name
},
},
services: {
'Validate argument': (context, event) => {
if (event.type !== 'Submit argument') return Promise.reject()
return new Promise((resolve, reject) => {
// TODO: figure out if we should validate argument data here or in the form itself,
// and if we should support people configuring a argument's validation function
resolve(event.data)
})
},
'Validate all arguments': (context, _) => {
return new Promise((resolve, reject) => {
for (const [argName, arg] of Object.entries(
context.argumentsToSubmit
).filter(([argName, _]) =>
context.selectedCommand?.args
? context.selectedCommand?.args[argName]?.required
: false
)) {
let argConfig = context.selectedCommand!.args![argName]
if (
typeof arg !== typeof argConfig.payload &&
typeof arg !== typeof argConfig.defaultValue &&
'options' in argConfig &&
typeof arg !== typeof argConfig.options[0].value
) {
return reject({
message: 'Argument payload is of the wrong type',
arg: {
...argConfig,
name: argName,
},
})
}
if (!arg && argConfig.required) {
return reject({
message: 'Argument payload is falsy but is required',
arg: {
...argConfig,
name: argName,
},
})
}
}
return resolve(context.argumentsToSubmit)
})
},
},
delays: {},
}
)
function sortCommands(a: Command, b: Command) {
if (b.ownerMachine === 'auth') return -1
if (a.ownerMachine === 'auth') return 1
return a.name.localeCompare(b.name)
}

View File

@ -0,0 +1,74 @@
// This file was automatically generated. Edits will be overwritten
export interface Typegen0 {
'@@xstate/typegen': true
internalEvents: {
'': { type: '' }
'done.invoke.validateArgument': {
type: 'done.invoke.validateArgument'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'done.invoke.validateArguments': {
type: 'done.invoke.validateArguments'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'error.platform.validateArgument': {
type: 'error.platform.validateArgument'
data: unknown
}
'error.platform.validateArguments': {
type: 'error.platform.validateArguments'
data: unknown
}
'xstate.init': { type: 'xstate.init' }
}
invokeSrcNameMap: {
'Validate all arguments': 'done.invoke.validateArguments'
'Validate argument': 'done.invoke.validateArgument'
}
missingImplementations: {
actions:
| 'Add arguments'
| 'Close dialog'
| 'Execute command'
| 'Open dialog'
delays: never
guards: never
services: never
}
eventsCausingActions: {
'Add arguments': 'done.invoke.validateArguments'
'Add commands': 'Add commands'
'Close dialog': 'Close'
'Execute command': '' | 'Submit'
'Open dialog': 'Open'
'Remove argument': 'Remove argument'
'Remove commands': 'Remove commands'
'Set current argument':
| 'Add argument'
| 'Edit argument'
| 'error.platform.validateArguments'
}
eventsCausingDelays: {}
eventsCausingGuards: {
'Arguments are ready': 'done.invoke.validateArguments'
'Command has no arguments': ''
}
eventsCausingServices: {
'Validate all arguments': 'done.invoke.validateArgument'
'Validate argument': 'Submit'
}
matchesStates:
| 'Checking Arguments'
| 'Closed'
| 'Command selected'
| 'Gathering arguments'
| 'Gathering arguments.Awaiting input'
| 'Gathering arguments.Validating'
| 'Review'
| 'Selecting command'
| { 'Gathering arguments'?: 'Awaiting input' | 'Validating' }
tags: never
}

View File

@ -1,60 +1,6 @@
import { assign, createMachine } from 'xstate' import { assign, createMachine } from 'xstate'
import { ProjectWithEntryPointMetadata } from '../Router' import { ProjectWithEntryPointMetadata } from '../Router'
import { CommandBarMeta } from '../lib/commands' import { HomeCommandSchema } from 'lib/commandBarConfigs/homeCommandConfig'
export const homeCommandMeta: CommandBarMeta = {
'Create project': {
displayValue: (args: string[]) => `Create project "${args[0]}"`,
args: [
{
name: 'name',
type: 'string',
description: '(default)',
options: 'defaultProjectName',
},
],
},
'Open project': {
displayValue: (args: string[]) => `Open project "${args[0]}"`,
args: [
{
name: 'name',
type: 'select',
options: 'projects',
},
],
},
'Delete project': {
displayValue: (args: string[]) => `Delete project "${args[0]}"`,
args: [
{
name: 'name',
type: 'select',
options: 'projects',
},
],
},
'Rename project': {
displayValue: (args: string[]) =>
`Rename project "${args[0]}" to "${args[1]}"`,
args: [
{
name: 'oldName',
type: 'select',
options: 'projects',
},
{
name: 'newName',
type: 'string',
description: '(default)',
options: 'defaultProjectName',
},
],
},
assign: {
hide: 'both',
},
}
export const homeMachine = createMachine( export const homeMachine = createMachine(
{ {
@ -192,10 +138,10 @@ export const homeMachine = createMachine(
schema: { schema: {
events: {} as events: {} as
| { type: 'Open project'; data: { name: string } } | { type: 'Open project'; data: HomeCommandSchema['Open project'] }
| { type: 'Rename project'; data: { oldName: string; newName: string } } | { type: 'Rename project'; data: HomeCommandSchema['Rename project'] }
| { type: 'Create project'; data: { name: string } } | { type: 'Create project'; data: HomeCommandSchema['Create project'] }
| { type: 'Delete project'; data: { name: string } } | { type: 'Delete project'; data: HomeCommandSchema['Delete project'] }
| { type: 'navigate'; data: { name: string } } | { type: 'navigate'; data: { name: string } }
| { | {
type: 'done.invoke.read-projects' type: 'done.invoke.read-projects'

File diff suppressed because one or more lines are too long

View File

@ -32,14 +32,14 @@
"Get vertical info": "done.invoke.get-vertical-info"; "Get vertical info": "done.invoke.get-vertical-info";
}; };
missingImplementations: { missingImplementations: {
actions: "AST add line segment" | "AST start new sketch" | "Modify AST" | "Set selection" | "Update code selection cursors" | "create path" | "set tool" | "show default planes" | "sketch exit execute" | "toast extrude failed"; actions: "AST add line segment" | "AST start new sketch" | "Modify AST" | "Set selection" | "Update code selection cursors" | "create path" | "set tool" | "show default planes" | "sketch exit execute";
delays: never; delays: never;
guards: "Selection contains axis" | "Selection contains edge" | "Selection contains face" | "Selection contains line" | "Selection contains point" | "Selection is not empty" | "Selection is one face"; guards: "Selection contains axis" | "Selection contains edge" | "Selection contains face" | "Selection contains line" | "Selection contains point" | "Selection is not empty" | "Selection is one face" | "has valid extrude selection";
services: "Get ABS X info" | "Get ABS Y info" | "Get angle info" | "Get horizontal info" | "Get length info" | "Get perpendicular distance info" | "Get vertical info"; services: "Get ABS X info" | "Get ABS Y info" | "Get angle info" | "Get horizontal info" | "Get length info" | "Get perpendicular distance info" | "Get vertical info";
}; };
eventsCausingActions: { eventsCausingActions: {
"AST add line segment": "Add point"; "AST add line segment": "Add point";
"AST extrude": "" | "extrude intent"; "AST extrude": "Extrude";
"AST start new sketch": "Add point"; "AST start new sketch": "Add point";
"Add to code-based selection": "Deselect point" | "Deselect segment" | "Select all" | "Select edge" | "Select face" | "Select point" | "Select segment"; "Add to code-based selection": "Deselect point" | "Deselect segment" | "Select all" | "Select edge" | "Select face" | "Select point" | "Select segment";
"Add to other selection": "Select axis"; "Add to other selection": "Select axis";
@ -63,7 +63,7 @@
"edit mode enter": "Enter sketch" | "Re-execute"; "edit mode enter": "Enter sketch" | "Re-execute";
"edit_mode_exit": "Cancel"; "edit_mode_exit": "Cancel";
"equip select": "CancelSketch" | "Constrain equal length" | "Constrain horizontally align" | "Constrain parallel" | "Constrain remove constraints" | "Constrain snap to X" | "Constrain snap to Y" | "Constrain vertically align" | "Deselect point" | "Deselect segment" | "Enter sketch" | "Make segment horizontal" | "Make segment vertical" | "Re-execute" | "Select default plane" | "Select point" | "Select segment" | "Set selection" | "done.invoke.get-abs-x-info" | "done.invoke.get-abs-y-info" | "done.invoke.get-angle-info" | "done.invoke.get-horizontal-info" | "done.invoke.get-length-info" | "done.invoke.get-perpendicular-distance-info" | "done.invoke.get-vertical-info" | "error.platform.get-abs-x-info" | "error.platform.get-abs-y-info" | "error.platform.get-angle-info" | "error.platform.get-horizontal-info" | "error.platform.get-length-info" | "error.platform.get-perpendicular-distance-info" | "error.platform.get-vertical-info"; "equip select": "CancelSketch" | "Constrain equal length" | "Constrain horizontally align" | "Constrain parallel" | "Constrain remove constraints" | "Constrain snap to X" | "Constrain snap to Y" | "Constrain vertically align" | "Deselect point" | "Deselect segment" | "Enter sketch" | "Make segment horizontal" | "Make segment vertical" | "Re-execute" | "Select default plane" | "Select point" | "Select segment" | "Set selection" | "done.invoke.get-abs-x-info" | "done.invoke.get-abs-y-info" | "done.invoke.get-angle-info" | "done.invoke.get-horizontal-info" | "done.invoke.get-length-info" | "done.invoke.get-perpendicular-distance-info" | "done.invoke.get-vertical-info" | "error.platform.get-abs-x-info" | "error.platform.get-abs-y-info" | "error.platform.get-angle-info" | "error.platform.get-horizontal-info" | "error.platform.get-length-info" | "error.platform.get-perpendicular-distance-info" | "error.platform.get-vertical-info";
"hide default planes": "Cancel" | "Select default plane" | "xstate.stop"; "hide default planes": "Cancel" | "Select default plane" | "Set selection" | "xstate.stop";
"reset sketch metadata": "Cancel" | "Select default plane"; "reset sketch metadata": "Cancel" | "Select default plane";
"set default plane id": "Select default plane"; "set default plane id": "Select default plane";
"set sketch metadata": "Enter sketch"; "set sketch metadata": "Enter sketch";
@ -72,9 +72,8 @@
"set tool line": "Equip tool"; "set tool line": "Equip tool";
"set tool move": "Equip move tool" | "Re-execute" | "Set selection"; "set tool move": "Equip move tool" | "Re-execute" | "Set selection";
"show default planes": "Enter sketch"; "show default planes": "Enter sketch";
"sketch exit execute": "Cancel" | "Complete line" | "xstate.stop"; "sketch exit execute": "Cancel" | "Complete line" | "Set selection" | "xstate.stop";
"sketch mode enabled": "Enter sketch" | "Re-execute" | "Select default plane"; "sketch mode enabled": "Enter sketch" | "Re-execute" | "Select default plane";
"toast extrude failed": "";
}; };
eventsCausingDelays: { eventsCausingDelays: {
@ -105,8 +104,7 @@
"Selection is one face": "Enter sketch"; "Selection is one face": "Enter sketch";
"can move": ""; "can move": "";
"can move with execute": ""; "can move with execute": "";
"has no selection": "extrude intent"; "has valid extrude selection": "Extrude";
"has valid extrude selection": "" | "extrude intent";
"is editing existing sketch": ""; "is editing existing sketch": "";
}; };
eventsCausingServices: { eventsCausingServices: {
@ -118,7 +116,7 @@
"Get perpendicular distance info": "Constrain perpendicular distance"; "Get perpendicular distance info": "Constrain perpendicular distance";
"Get vertical info": "Constrain vertical distance"; "Get vertical info": "Constrain vertical distance";
}; };
matchesStates: "Sketch" | "Sketch no face" | "Sketch.Await ABS X info" | "Sketch.Await ABS Y info" | "Sketch.Await angle info" | "Sketch.Await horizontal distance info" | "Sketch.Await length info" | "Sketch.Await perpendicular distance info" | "Sketch.Await vertical distance info" | "Sketch.Line Tool" | "Sketch.Line Tool.Done" | "Sketch.Line Tool.Init" | "Sketch.Line Tool.No Points" | "Sketch.Line Tool.Point Added" | "Sketch.Line Tool.Segment Added" | "Sketch.Move Tool" | "Sketch.Move Tool.Move init" | "Sketch.Move Tool.Move with execute" | "Sketch.Move Tool.Move without re-execute" | "Sketch.Move Tool.No move" | "Sketch.SketchIdle" | "awaiting selection" | "checking selection" | "idle" | { "Sketch"?: "Await ABS X info" | "Await ABS Y info" | "Await angle info" | "Await horizontal distance info" | "Await length info" | "Await perpendicular distance info" | "Await vertical distance info" | "Line Tool" | "Move Tool" | "SketchIdle" | { "Line Tool"?: "Done" | "Init" | "No Points" | "Point Added" | "Segment Added"; matchesStates: "Sketch" | "Sketch no face" | "Sketch.Await ABS X info" | "Sketch.Await ABS Y info" | "Sketch.Await angle info" | "Sketch.Await horizontal distance info" | "Sketch.Await length info" | "Sketch.Await perpendicular distance info" | "Sketch.Await vertical distance info" | "Sketch.Line Tool" | "Sketch.Line Tool.Done" | "Sketch.Line Tool.Init" | "Sketch.Line Tool.No Points" | "Sketch.Line Tool.Point Added" | "Sketch.Line Tool.Segment Added" | "Sketch.Move Tool" | "Sketch.Move Tool.Move init" | "Sketch.Move Tool.Move with execute" | "Sketch.Move Tool.Move without re-execute" | "Sketch.Move Tool.No move" | "Sketch.SketchIdle" | "idle" | { "Sketch"?: "Await ABS X info" | "Await ABS Y info" | "Await angle info" | "Await horizontal distance info" | "Await length info" | "Await perpendicular distance info" | "Await vertical distance info" | "Line Tool" | "Move Tool" | "SketchIdle" | { "Line Tool"?: "Done" | "Init" | "No Points" | "Point Added" | "Segment Added";
"Move Tool"?: "Move init" | "Move with execute" | "Move without re-execute" | "No move"; }; }; "Move Tool"?: "Move init" | "Move with execute" | "Move without re-execute" | "No move"; }; };
tags: never; tags: never;
} }

View File

@ -1,7 +1,6 @@
import { assign, createMachine } from 'xstate' import { assign, createMachine } from 'xstate'
import { CommandBarMeta } from '../lib/commands'
import { Themes, getSystemTheme, setThemeClass } from '../lib/theme' import { Themes, getSystemTheme, setThemeClass } from '../lib/theme'
import { CameraSystem, cameraSystems } from 'lib/cameraControls' import { CameraSystem } from 'lib/cameraControls'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
export const DEFAULT_PROJECT_NAME = 'project-$nnn' export const DEFAULT_PROJECT_NAME = 'project-$nnn'
@ -24,85 +23,6 @@ export type Toggle = 'On' | 'Off'
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY' export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
export const settingsCommandBarMeta: CommandBarMeta = {
'Set Base Unit': {
displayValue: (args: string[]) => 'Set your default base unit',
args: [
{
name: 'baseUnit',
type: 'select',
defaultValue: 'baseUnit',
options: Object.values(baseUnitsUnion).map((v) => ({ name: v })),
},
],
},
'Set Camera Controls': {
displayValue: (args: string[]) => 'Set your camera controls',
args: [
{
name: 'cameraControls',
type: 'select',
defaultValue: 'cameraControls',
options: Object.values(cameraSystems).map((v) => ({ name: v })),
},
],
},
'Set Default Directory': {
hide: 'both',
},
'Set Default Project Name': {
displayValue: (args: string[]) => 'Set a new default project name',
hide: 'web',
args: [
{
name: 'defaultProjectName',
type: 'string',
description: '(default)',
defaultValue: 'defaultProjectName',
options: 'defaultProjectName',
},
],
},
'Set Onboarding Status': {
hide: 'both',
},
'Set Text Wrapping': {
displayValue: (args: string[]) => 'Set whether text in the editor wraps',
args: [
{
name: 'textWrapping',
type: 'select',
defaultValue: 'textWrapping',
options: [{ name: 'On' }, { name: 'Off' }],
},
],
},
'Set Theme': {
displayValue: (args: string[]) => 'Change the app theme',
args: [
{
name: 'theme',
type: 'select',
defaultValue: 'theme',
options: Object.values(Themes).map((v): { name: string } => ({
name: v,
})),
},
],
},
'Set Unit System': {
displayValue: (args: string[]) => 'Set your default unit system',
args: [
{
name: 'unitSystem',
type: 'select',
defaultValue: 'unitSystem',
options: [{ name: UnitSystem.Imperial }, { name: UnitSystem.Metric }],
},
],
},
}
export const settingsMachine = createMachine( export const settingsMachine = createMachine(
{ {
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */ /** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */
@ -126,7 +46,12 @@ export const settingsMachine = createMachine(
on: { on: {
'Set Base Unit': { 'Set Base Unit': {
actions: [ actions: [
assign({ baseUnit: (_, event) => event.data.baseUnit }), assign({
baseUnit: (_, event) => {
console.log('event', event)
return event.data.baseUnit
},
}),
'persistSettings', 'persistSettings',
'toastSuccess', 'toastSuccess',
], ],

View File

@ -17,7 +17,7 @@ import { Link } from 'react-router-dom'
import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router' import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
import Loading from '../components/Loading' import Loading from '../components/Loading'
import { useMachine } from '@xstate/react' import { useMachine } from '@xstate/react'
import { homeCommandMeta, homeMachine } from '../machines/homeMachine' import { homeMachine } from '../machines/homeMachine'
import { ContextFrom, EventFrom } from 'xstate' import { ContextFrom, EventFrom } from 'xstate'
import { paths } from '../Router' import { paths } from '../Router'
import { import {
@ -30,11 +30,12 @@ import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { DEFAULT_PROJECT_NAME } from 'machines/settingsMachine' import { DEFAULT_PROJECT_NAME } from 'machines/settingsMachine'
import { sep } from '@tauri-apps/api/path' import { sep } from '@tauri-apps/api/path'
import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig'
// This route only opens in the Tauri desktop context for now, // This route only opens in the Tauri desktop context for now,
// as defined in Router.tsx, so we can use the Tauri APIs and types. // as defined in Router.tsx, so we can use the Tauri APIs and types.
const Home = () => { const Home = () => {
const { commands, setCommandBarOpen } = useCommandsContext() const { commandBarSend } = useCommandsContext()
const navigate = useNavigate() const navigate = useNavigate()
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
const { const {
@ -56,7 +57,7 @@ const Home = () => {
event: EventFrom<typeof homeMachine> event: EventFrom<typeof homeMachine>
) => { ) => {
if (event.data && 'name' in event.data) { if (event.data && 'name' in event.data) {
setCommandBarOpen(false) commandBarSend({ type: 'Close' })
navigate( navigate(
`${paths.FILE}/${encodeURIComponent( `${paths.FILE}/${encodeURIComponent(
context.defaultDirectory + sep + event.data.name context.defaultDirectory + sep + event.data.name
@ -143,12 +144,11 @@ const Home = () => {
const isSortByModified = sort?.includes('modified') || !sort || sort === null const isSortByModified = sort?.includes('modified') || !sort || sort === null
useStateMachineCommands<typeof homeMachine>({ useStateMachineCommands({
commands, machineId: 'home',
send, send,
state, state,
commandBarMeta: homeCommandMeta, commandBarConfig: homeCommandBarConfig,
owner: 'home',
}) })
useEffect(() => { useEffect(() => {
@ -178,23 +178,24 @@ const Home = () => {
<div className="w-full max-w-5xl px-4 mx-auto my-24 overflow-y-auto lg:px-0"> <div className="w-full max-w-5xl px-4 mx-auto my-24 overflow-y-auto lg:px-0">
<section className="flex justify-between"> <section className="flex justify-between">
<h1 className="text-3xl text-bold">Your Projects</h1> <h1 className="text-3xl text-bold">Your Projects</h1>
<div className="flex"> <div className="flex gap-2 items-center">
<small>Sort by</small>
<ActionButton <ActionButton
Element="button" Element="button"
className={ className={
!sort.includes('name') 'text-sm ' +
(!sort.includes('name')
? 'text-chalkboard-80 dark:text-chalkboard-40' ? 'text-chalkboard-80 dark:text-chalkboard-40'
: '' : '')
} }
onClick={() => setSearchParams(getNextSearchParams(sort, 'name'))} onClick={() => setSearchParams(getNextSearchParams(sort, 'name'))}
icon={{ icon={{
icon: getSortIcon(sort, 'name'), icon: getSortIcon(sort, 'name'),
bgClassName: !sort?.includes('name') className: 'p-1.5',
? 'bg-liquid-50 dark:bg-liquid-70' iconClassName: !sort.includes('name')
: '', ? '!text-chalkboard-40'
iconClassName: !sort?.includes('name')
? 'text-liquid-80 dark:text-liquid-30'
: '', : '',
size: 'sm',
}} }}
> >
Name Name
@ -202,21 +203,19 @@ const Home = () => {
<ActionButton <ActionButton
Element="button" Element="button"
className={ className={
!isSortByModified 'text-sm ' +
(!isSortByModified
? 'text-chalkboard-80 dark:text-chalkboard-40' ? 'text-chalkboard-80 dark:text-chalkboard-40'
: '' : '')
} }
onClick={() => onClick={() =>
setSearchParams(getNextSearchParams(sort, 'modified')) setSearchParams(getNextSearchParams(sort, 'modified'))
} }
icon={{ icon={{
icon: sort ? getSortIcon(sort, 'modified') : faArrowDown, icon: sort ? getSortIcon(sort, 'modified') : faArrowDown,
bgClassName: !isSortByModified className: 'p-1.5',
? 'bg-liquid-50 dark:bg-liquid-70' iconClassName: !isSortByModified ? '!text-chalkboard-40' : '',
: '', size: 'sm',
iconClassName: !isSortByModified
? 'text-liquid-80 dark:text-liquid-30'
: '',
}} }}
> >
Last Modified Last Modified
@ -225,11 +224,15 @@ const Home = () => {
</section> </section>
<section> <section>
<p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30"> <p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
Are being saved at{' '} Loaded from{' '}
<code className="text-liquid-80 dark:text-liquid-30"> <span className="text-energy-70 dark:text-energy-40">
{defaultDirectory} {defaultDirectory}
</code> </span>
, which you can change in your <Link to="settings">Settings</Link>. .{' '}
<Link to="settings" className="underline underline-offset-2">
Edit in settings
</Link>
.
</p> </p>
{state.matches('Reading projects') ? ( {state.matches('Reading projects') ? (
<Loading>Loading your Projects...</Loading> <Loading>Loading your Projects...</Loading>
@ -254,7 +257,7 @@ const Home = () => {
<ActionButton <ActionButton
Element="button" Element="button"
onClick={() => send('Create project')} onClick={() => send('Create project')}
icon={{ icon: faPlus }} icon={{ icon: faPlus, iconClassName: 'p-1 w-4' }}
data-testid="home-new-file" data-testid="home-new-file"
> >
New file New file

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
import { SettingsSection } from 'routes/Settings' import { SettingsSection } from 'routes/Settings'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext' import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
@ -70,28 +68,11 @@ export default function Units() {
</li> </li>
</ul> </ul>
</SettingsSection> </SettingsSection>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton dismiss={dismiss}
Element="button" next={next}
onClick={dismiss} nextText="Next: Streaming"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Streaming
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,9 +1,6 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import usePlatform from 'hooks/usePlatform'
import { ActionButton } from '../../components/ActionButton' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
import { Platform, platform } from '@tauri-apps/api/os'
import { useEffect, useState } from 'react'
export default function CmdK() { export default function CmdK() {
const { buttonDownInStream } = useStore((s) => ({ const { buttonDownInStream } = useStore((s) => ({
@ -11,14 +8,7 @@ export default function CmdK() {
})) }))
const dismiss = useDismiss() const dismiss = useDismiss()
const next = useNextClick(onboardingPaths.USER_MENU) const next = useNextClick(onboardingPaths.USER_MENU)
const [platformName, setPlatformName] = useState<Platform | ''>('') const platformName = usePlatform()
useEffect(() => {
async function getPlatform() {
setPlatformName(await platform())
}
getPlatform()
}, [setPlatformName])
return ( return (
<div className="fixed inset-0 z-50 grid items-end justify-center pointer-events-none"> <div className="fixed inset-0 z-50 grid items-end justify-center pointer-events-none">
@ -31,13 +21,13 @@ export default function CmdK() {
<h2 className="text-2xl">Command Bar</h2> <h2 className="text-2xl">Command Bar</h2>
<p className="my-4"> <p className="my-4">
Press{' '} Press{' '}
{platformName === 'win32' ? ( {platformName === 'darwin' ? (
<> <>
<kbd>Win</kbd> + <kbd>/</kbd> <kbd></kbd> + <kbd>K</kbd>
</> </>
) : ( ) : (
<> <>
<kbd>OS</kbd> + <kbd>K</kbd> <kbd>Ctrl</kbd> + <kbd>/</kbd>
</> </>
)}{' '} )}{' '}
to open the command bar. Try changing your theme with it. to open the command bar. Try changing your theme with it.
@ -57,28 +47,11 @@ export default function CmdK() {
management from the command bar, but we will be powering modeling management from the command bar, but we will be powering modeling
commands with it soon. commands with it soon.
</p> </p>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton dismiss={dismiss}
Element="button" next={next}
onClick={dismiss} nextText="Next: User Menu"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: User Menu
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
import { useBackdropHighlight } from 'hooks/useBackdropHighlight' import { useBackdropHighlight } from 'hooks/useBackdropHighlight'
@ -57,28 +55,11 @@ export default function CodeEditor() {
<kbd>Shift</kbd> + <kbd>C</kbd>. <kbd>Shift</kbd> + <kbd>C</kbd>.
</p> </p>
</section> </section>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton dismiss={dismiss}
Element="button" next={next}
onClick={dismiss} nextText="Next: Parametric Modeling"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Parametric Modeling
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
export default function Export() { export default function Export() {
@ -44,29 +42,11 @@ export default function Export() {
export to almost any CAD software. export to almost any CAD software.
</p> </p>
</section> </section>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton next={next}
Element="button" dismiss={dismiss}
onClick={dismiss} nextText="Next: Sketching"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
data-testid="onboarding-next"
>
Next: Sketching
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, useDismiss } from '.'
import { ActionButton } from '../../components/ActionButton'
import { useDismiss } from '.'
import { useEffect } from 'react' import { useEffect } from 'react'
import { bracket } from 'lib/exampleKcl' import { bracket } from 'lib/exampleKcl'
import { kclManager } from 'lang/KclSinglton' import { kclManager } from 'lang/KclSinglton'
@ -38,28 +36,12 @@ export default function FutureWork() {
hardware design with us 💚. hardware design with us 💚.
</p> </p>
<p className="my-4"> The KittyCAD Team</p> <p className="my-4"> The KittyCAD Team</p>
<div className="flex justify-between mt-6"> <OnboardingButtons
<ActionButton className="mt-6"
Element="button" dismiss={dismiss}
onClick={dismiss} next={dismiss}
icon={{ nextText="Finish"
icon: faXmark, />
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={dismiss}
icon={{ icon: faArrowRight }}
>
Finish
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
import { useBackdropHighlight } from 'hooks/useBackdropHighlight' import { useBackdropHighlight } from 'hooks/useBackdropHighlight'
@ -97,28 +95,11 @@ export default function InteractiveNumbers() {
we'd love to hear your ideas for how to make it better. we'd love to hear your ideas for how to make it better.
</p> </p>
</section> </section>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton dismiss={dismiss}
Element="button" next={next}
onClick={dismiss} nextText="Next: Command Bar"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Command Bar
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,7 +1,6 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../../components/ActionButton'
import { import {
ONBOARDING_PROJECT_NAME, ONBOARDING_PROJECT_NAME,
OnboardingButtons,
onboardingPaths, onboardingPaths,
useDismiss, useDismiss,
useNextClick, useNextClick,
@ -65,31 +64,15 @@ function OnboardingWithNewFile() {
We see you have some of your own code written in this project. We see you have some of your own code written in this project.
Please save it somewhere else before continuing the onboarding. Please save it somewhere else before continuing the onboarding.
</p> </p>
<div className="flex justify-between mt-6"> <OnboardingButtons
<ActionButton className="mt-6"
Element="button" dismiss={dismiss}
onClick={dismiss} next={() => {
icon={{ kclManager.setCodeAndExecute(bracket)
icon: faXmark, next()
bgClassName: 'bg-destroy-80', }}
iconClassName: nextText="Overwrite code and continue"
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10', />
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={() => {
kclManager.setCodeAndExecute(bracket)
next()
}}
icon={{ icon: faArrowRight }}
>
Overwrite code and continue
</ActionButton>
</div>
</> </>
) : ( ) : (
<> <>
@ -103,32 +86,16 @@ function OnboardingWithNewFile() {
click the button below. click the button below.
</p> </p>
</section> </section>
<div className="flex justify-between mt-6"> <OnboardingButtons
<ActionButton className="mt-6"
Element="button" dismiss={dismiss}
onClick={dismiss} next={() => {
icon={{ void createAndOpenNewProject()
icon: faXmark, kclManager.setCode(bracket, false)
bgClassName: 'bg-destroy-80', dismiss()
iconClassName: }}
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10', nextText="Make a new project"
}} />
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={() => {
createAndOpenNewProject()
kclManager.setCode(bracket, false)
dismiss()
}}
icon={{ icon: faArrowRight }}
>
Make a new project
</ActionButton>
</div>
</> </>
)} )}
</div> </div>
@ -192,28 +159,12 @@ export default function Introduction() {
release as early as possible to get feedback from users like you. release as early as possible to get feedback from users like you.
</p> </p>
</section> </section>
<div className="flex justify-between mt-6"> <OnboardingButtons
<ActionButton className="mt-6"
Element="button" dismiss={dismiss}
onClick={dismiss} next={next}
icon={{ nextText="Camera"
icon: faXmark, />
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Get Started
</ActionButton>
</div>
</div> </div>
</div> </div>
) : ( ) : (

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
import { useBackdropHighlight } from 'hooks/useBackdropHighlight' import { useBackdropHighlight } from 'hooks/useBackdropHighlight'
import { Themes, getSystemTheme } from 'lib/theme' import { Themes, getSystemTheme } from 'lib/theme'
@ -57,28 +55,11 @@ export default function ParametricModeling() {
on the width of the bracket to meet a set safety factor on line 6. on the width of the bracket to meet a set safety factor on line 6.
</p> </p>
</section> </section>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton dismiss={dismiss}
Element="button" next={next}
onClick={dismiss} nextText="Next: Interactive Numbers"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Interactive Numbers
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
@ -28,28 +26,11 @@ export default function ProjectMenu() {
we add support for multi-file assemblies. we add support for multi-file assemblies.
</p> </p>
</section> </section>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton next={next}
Element="button" dismiss={dismiss}
onClick={dismiss} nextText="Next: Export"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Export
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from 'useStore' import { useStore } from 'useStore'
import { useEffect } from 'react' import { useEffect } from 'react'
import { kclManager } from 'lang/KclSinglton' import { kclManager } from 'lang/KclSinglton'
@ -39,29 +37,12 @@ export default function Sketching() {
Watch the code pane as you click. Point-and-click interactions are Watch the code pane as you click. Point-and-click interactions are
always just modifying and generating code in KittyCAD Modeling App. always just modifying and generating code in KittyCAD Modeling App.
</p> </p>
<div className="flex justify-between mt-6"> <OnboardingButtons
<ActionButton className="mt-6"
Element="button" next={next}
onClick={dismiss} dismiss={dismiss}
icon={{ nextText="Next: Future Work"
icon: faXmark, />
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
data-testid="onboarding-next"
>
Next: Future Work
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
export default function Streaming() { export default function Streaming() {
@ -38,28 +36,11 @@ export default function Streaming() {
and you won't have to worry about the performance of the device. and you won't have to worry about the performance of the device.
</p> </p>
</section> </section>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton dismiss={dismiss}
Element="button" next={next}
onClick={dismiss} nextText="Next: Code Editor"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Code Editing
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
export default function UserMenu() { export default function UserMenu() {
@ -25,28 +23,11 @@ export default function UserMenu() {
change your settings, sign out, or request a feature. change your settings, sign out, or request a feature.
</p> </p>
</section> </section>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton dismiss={dismiss}
Element="button" next={next}
onClick={dismiss} nextText="Next: Project Menu"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Project Menu
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -17,6 +17,7 @@ import Export from './Export'
import FutureWork from './FutureWork' import FutureWork from './FutureWork'
import { paths } from 'Router' import { paths } from 'Router'
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
import { ActionButton } from 'components/ActionButton'
export const ONBOARDING_PROJECT_NAME = 'Tutorial Project $nn' export const ONBOARDING_PROJECT_NAME = 'Tutorial Project $nn'
@ -120,6 +121,45 @@ export function useDismiss() {
}, [send, navigate, filePath]) }, [send, navigate, filePath])
} }
export function OnboardingButtons({
next,
nextText,
dismiss,
className,
...props
}: {
next: () => void
nextText?: string
dismiss: () => void
className?: string
} & React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={'flex justify-between ' + (className ?? '')} {...props}>
<ActionButton
Element="button"
onClick={dismiss}
icon={{
icon: 'close',
bgClassName: 'bg-destroy-80',
iconClassName: 'text-destroy-20 group-hover:text-destroy-10',
}}
className="hover:border-destroy-40 hover:bg-destroy-10/50 dark:hover:bg-destroy-80/50"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: 'arrowRight', bgClassName: 'dark:bg-chalkboard-80' }}
className="dark:hover:bg-chalkboard-80/50"
data-testid="onboarding-next"
>
{nextText ?? 'Next'}
</ActionButton>
</div>
)
}
const Onboarding = () => { const Onboarding = () => {
const dismiss = useDismiss() const dismiss = useDismiss()
useHotkeys('esc', dismiss) useHotkeys('esc', dismiss)

View File

@ -1,8 +1,4 @@
import { import { faArrowRotateBack, faXmark } from '@fortawesome/free-solid-svg-icons'
faArrowRotateBack,
faFolder,
faXmark,
} from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../components/ActionButton' import { ActionButton } from '../components/ActionButton'
import { AppHeader } from '../components/AppHeader' import { AppHeader } from '../components/AppHeader'
import { open } from '@tauri-apps/api/dialog' import { open } from '@tauri-apps/api/dialog'
@ -185,14 +181,9 @@ export const Settings = () => {
/> />
<ActionButton <ActionButton
Element="button" Element="button"
className="bg-chalkboard-100 dark:bg-chalkboard-90 hover:bg-chalkboard-90 dark:hover:bg-chalkboard-80 !text-chalkboard-10 border-chalkboard-100 hover:border-chalkboard-70"
onClick={handleDirectorySelection} onClick={handleDirectorySelection}
icon={{ icon={{
icon: faFolder, icon: 'folder',
bgClassName:
'bg-liquid-20 group-hover:bg-liquid-10 hover:bg-liquid-10',
iconClassName:
'text-liquid-90 group-hover:text-liquid-90 hover:text-liquid-90',
}} }}
> >
Choose a folder Choose a folder
@ -305,7 +296,7 @@ export const Settings = () => {
<ActionButton <ActionButton
Element="button" Element="button"
onClick={restartOnboarding} onClick={restartOnboarding}
icon={{ icon: faArrowRotateBack }} icon={{ icon: faArrowRotateBack, size: 'sm', className: 'p-1' }}
> >
Replay Onboarding Replay Onboarding
</ActionButton> </ActionButton>

View File

@ -1,4 +1,3 @@
import { faSignInAlt } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../components/ActionButton' import { ActionButton } from '../components/ActionButton'
import { isTauri } from '../lib/isTauri' import { isTauri } from '../lib/isTauri'
import { invoke } from '@tauri-apps/api/tauri' import { invoke } from '@tauri-apps/api/tauri'
@ -65,7 +64,7 @@ const SignIn = () => {
<ActionButton <ActionButton
Element="button" Element="button"
onClick={signInTauri} onClick={signInTauri}
icon={{ icon: faSignInAlt }} icon={{ icon: 'arrowRight' }}
className="w-fit mt-4" className="w-fit mt-4"
data-testid="sign-in-button" data-testid="sign-in-button"
> >
@ -80,7 +79,7 @@ const SignIn = () => {
typeof window !== 'undefined' && typeof window !== 'undefined' &&
window.location.href.replace('signin', '') window.location.href.replace('signin', '')
)}`} )}`}
icon={{ icon: faSignInAlt }} icon={{ icon: 'arrowRight' }}
className="w-fit mt-4" className="w-fit mt-4"
> >
Sign in Sign in

View File

@ -677,9 +677,9 @@ dependencies = [
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.4.0" version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5"
[[package]] [[package]]
name = "databake" name = "databake"
@ -822,16 +822,6 @@ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
[[package]]
name = "execution-plan"
version = "0.1.0"
dependencies = [
"bytes",
"kittycad",
"serde",
"thiserror",
]
[[package]] [[package]]
name = "expectorate" name = "expectorate"
version = "1.1.0" version = "1.1.0"

View File

@ -54,7 +54,6 @@ members = [
"derive-docs", "derive-docs",
"kcl", "kcl",
"kcl-macros", "kcl-macros",
"execution-plan",
] ]
[workspace.dependencies] [workspace.dependencies]

View File

@ -1,13 +0,0 @@
[package]
name = "execution-plan"
version = "0.1.0"
edition = "2021"
repository = "https://github.com/KittyCAD/modeling-app"
rust-version = "1.73"
description = "A DSL for composing KittyCAD API queries"
[dependencies]
bytes = "1.5"
kittycad = { workspace = true, features = ["requests"] }
serde = { version = "1", features = ["derive"] }
thiserror = "1"

View File

@ -1,39 +0,0 @@
use crate::{ExecutionError, Value};
/// Types that can be written to or read from KCEP program memory,
/// but require multiple values to store.
/// They get laid out into multiple consecutive memory addresses.
pub trait Composite: Sized {
/// How many memory addresses are required to store this value?
const SIZE: usize;
/// Store the value in memory.
fn into_parts(self) -> Vec<Value>;
/// Read the value from memory.
fn from_parts(values: Vec<Value>) -> Result<Self, ExecutionError>;
}
impl Composite for kittycad::types::Point3D {
fn into_parts(self) -> Vec<Value> {
let points = [self.x, self.y, self.z];
points
.into_iter()
.map(|x| Value::NumericValue(crate::NumericValue::Float(x)))
.collect()
}
const SIZE: usize = 3;
fn from_parts(values: Vec<Value>) -> Result<Self, ExecutionError> {
let n = values.len();
let Ok([x, y, z]): Result<[Value; 3], _> = values.try_into() else {
return Err(ExecutionError::MemoryWrongSize {
actual: n,
expected: Self::SIZE,
});
};
let x = x.try_into()?;
let y = y.try_into()?;
let z = z.try_into()?;
Ok(Self { x, y, z })
}
}

View File

@ -1,278 +0,0 @@
//! A KittyCAD execution plan (KCEP) is a list of
//! - KittyCAD API requests to make
//! - Values to send in API requests
//! - Values to assign from API responses
//! - Computation to perform on values
//! You can think of it as a domain-specific language for making KittyCAD API calls and using
//! the results to make other API calls.
use composite::Composite;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fmt};
mod composite;
#[cfg(test)]
mod tests;
/// KCEP's program memory. A flat, linear list of values.
#[derive(Default, Debug)]
#[cfg_attr(test, derive(PartialEq))]
pub struct Memory(HashMap<usize, Value>);
/// An address in KCEP's program memory.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Address(usize);
impl fmt::Display for Address {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl From<usize> for Address {
fn from(value: usize) -> Self {
Self(value)
}
}
impl Memory {
/// Get a value from KCEP's program memory.
pub fn get(&self, addr: &Address) -> Option<&Value> {
self.0.get(&addr.0)
}
/// Store a value in KCEP's program memory.
pub fn set(&mut self, addr: Address, value: Value) {
self.0.insert(addr.0, value);
}
/// Store a composite value (i.e. a value which takes up multiple addresses in memory).
/// Store its parts in consecutive memory addresses starting at `start`.
pub fn set_composite<T: Composite>(&mut self, composite_value: T, start: Address) {
let parts = composite_value.into_parts().into_iter();
for (value, addr) in parts.zip(start.0..) {
self.0.insert(addr, value);
}
}
/// Get a composite value (i.e. a value which takes up multiple addresses in memory).
/// Its parts are stored in consecutive memory addresses starting at `start`.
pub fn get_composite<T: Composite>(&self, start: Address) -> Result<T, ExecutionError> {
let addrs = start.0..start.0 + T::SIZE;
let values: Vec<Value> = addrs
.into_iter()
.map(|a| {
let addr = Address(a);
self.get(&addr)
.map(|x| x.to_owned())
.ok_or(ExecutionError::MemoryEmpty { addr })
})
.collect::<Result<_, _>>()?;
T::from_parts(values)
}
}
/// A value stored in KCEP program memory.
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub enum Value {
String(String),
NumericValue(NumericValue),
}
impl TryFrom<Value> for f64 {
type Error = ExecutionError;
fn try_from(value: Value) -> Result<Self, Self::Error> {
if let Value::NumericValue(NumericValue::Float(x)) = value {
Ok(x)
} else {
Err(ExecutionError::MemoryWrongType {
expected: "float",
actual: format!("{value:?}"),
})
}
}
}
#[cfg(test)]
impl From<f64> for Value {
fn from(value: f64) -> Self {
Self::NumericValue(NumericValue::Float(value))
}
}
#[cfg(test)]
impl From<usize> for Value {
fn from(value: usize) -> Self {
Self::NumericValue(NumericValue::Integer(value))
}
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub enum NumericValue {
Integer(usize),
Float(f64),
}
/// One step of the execution plan.
#[derive(Serialize, Deserialize)]
pub enum Instruction {
/// Call the KittyCAD API.
ApiRequest {
/// Which ModelingCmd to call.
/// It's a composite value starting at the given address.
endpoint: Address,
/// Which address should the response be stored in?
store_response: Option<usize>,
/// Look up each API request in this register number.
arguments: Vec<Address>,
},
/// Set a value in memory.
Set {
/// Which memory address to set.
address: Address,
/// What value to set the memory address to.
value: Value,
},
/// Perform arithmetic on values in memory.
Arithmetic {
/// What to do.
arithmetic: Arithmetic,
/// Write the output to this memory address.
destination: Address,
},
}
/// Instruction to perform arithmetic on values in memory.
#[derive(Deserialize, Serialize)]
pub struct Arithmetic {
/// Apply this operation
pub operation: Operation,
/// First operand for the operation
pub operand0: Operand,
/// Second operand for the operation
pub operand1: Operand,
}
macro_rules! arithmetic_body {
($arith:ident, $mem:ident, $method:ident) => {
match (
$arith.operand0.eval(&$mem)?.clone(),
$arith.operand1.eval(&$mem)?.clone(),
) {
// If both operands are numeric, then do the arithmetic operation.
(Value::NumericValue(x), Value::NumericValue(y)) => {
let num = match (x, y) {
(NumericValue::Integer(x), NumericValue::Integer(y)) => NumericValue::Integer(x.$method(y)),
(NumericValue::Integer(x), NumericValue::Float(y)) => NumericValue::Float((x as f64).$method(y)),
(NumericValue::Float(x), NumericValue::Integer(y)) => NumericValue::Float(x.$method(y as f64)),
(NumericValue::Float(x), NumericValue::Float(y)) => NumericValue::Float(x.$method(y)),
};
Ok(Value::NumericValue(num))
}
// This operation can only be done on numeric types.
_ => Err(ExecutionError::CannotApplyOperation {
op: $arith.operation,
operands: vec![
$arith.operand0.eval(&$mem)?.clone().to_owned(),
$arith.operand1.eval(&$mem)?.clone().to_owned(),
],
}),
}
};
}
impl Arithmetic {
/// Calculate the the arithmetic equation.
/// May read values from the given memory.
fn calculate(self, mem: &Memory) -> Result<Value, ExecutionError> {
use std::ops::{Add, Div, Mul, Sub};
match self.operation {
Operation::Add => {
arithmetic_body!(self, mem, add)
}
Operation::Mul => {
arithmetic_body!(self, mem, mul)
}
Operation::Sub => {
arithmetic_body!(self, mem, sub)
}
Operation::Div => {
arithmetic_body!(self, mem, div)
}
}
}
}
/// Operations that can be applied to values in memory.
#[derive(Debug, Deserialize, Serialize)]
pub enum Operation {
Add,
Mul,
Sub,
Div,
}
impl fmt::Display for Operation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Operation::Add => "+",
Operation::Mul => "*",
Operation::Sub => "-",
Operation::Div => "/",
}
.fmt(f)
}
}
/// Argument to an operation.
#[derive(Deserialize, Serialize)]
pub enum Operand {
Literal(Value),
Reference(Address),
}
impl Operand {
/// Evaluate the operand, getting its value.
fn eval(&self, mem: &Memory) -> Result<Value, ExecutionError> {
match self {
Operand::Literal(v) => Ok(v.to_owned()),
Operand::Reference(addr) => match mem.get(addr) {
None => Err(ExecutionError::MemoryEmpty { addr: *addr }),
Some(v) => Ok(v.to_owned()),
},
}
}
}
/// Execute the plan.
pub fn execute(mem: &mut Memory, plan: Vec<Instruction>) -> Result<(), ExecutionError> {
for step in plan {
match step {
Instruction::ApiRequest { .. } => todo!("Execute API calls"),
Instruction::Set { address, value } => {
mem.set(address, value);
}
Instruction::Arithmetic {
arithmetic,
destination,
} => {
let out = arithmetic.calculate(mem)?;
mem.set(destination, out);
}
}
}
Ok(())
}
/// Errors that could occur when executing a KittyCAD execution plan.
#[derive(Debug, thiserror::Error)]
pub enum ExecutionError {
#[error("Memory address {addr} was not set")]
MemoryEmpty { addr: Address },
#[error("Cannot apply operation {op} to operands {operands:?}")]
CannotApplyOperation { op: Operation, operands: Vec<Value> },
#[error("Tried to read a '{expected}' from KCEP program memory, found an '{actual}' instead")]
MemoryWrongType { expected: &'static str, actual: String },
#[error("Wrong size of memory trying to read value from KCEP program memory: got {actual} but wanted {expected}")]
MemoryWrongSize { expected: usize, actual: usize },
}

Some files were not shown because too many files have changed in this diff Show More