Skip to content

Commit f786e17

Browse files
authored
feat: Add support for navigating in mutator workspaces. (#600)
* feat: Add support for navigating into mutators. * chore: Add tests for navigating in mutators. * chore: Remove unused imports. * fix: Add missing return. * chore: Remove .only clause. * fix: Fix bad merge. * chore: Add test for exiting mutator flyout. * chore: Add comment clarifying resolution of workspaces for checking navigability. * chore: Add a comment explaining icon navigation.
1 parent 5c9df6a commit f786e17

File tree

5 files changed

+177
-3
lines changed

5 files changed

+177
-3
lines changed

src/actions/enter.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
Field,
1616
icons,
1717
FocusableTreeTraverser,
18+
renderManagement,
1819
} from 'blockly/core';
1920

2021
import type {Block} from 'blockly/core';
@@ -151,7 +152,13 @@ export class EnterAction {
151152
this.navigation.openToolboxOrFlyout(workspace);
152153
return true;
153154
} else if (curNode instanceof icons.Icon) {
155+
// Calling the icon's click handler will trigger its action, generally
156+
// opening a bubble of some sort. We then need to wait for the bubble to
157+
// appear before attempting to navigate into it.
154158
curNode.onClick();
159+
renderManagement.finishQueuedRenders().then(() => {
160+
cursor?.in();
161+
});
155162
return true;
156163
}
157164
return false;

src/actions/exit.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
utils as BlocklyUtils,
1010
getFocusManager,
1111
Gesture,
12+
icons,
1213
} from 'blockly/core';
1314

1415
import * as Constants from '../constants';
@@ -39,6 +40,26 @@ export class ExitAction {
3940
workspace.hideChaff();
4041
}
4142
return true;
43+
case Constants.STATE.WORKSPACE: {
44+
if (workspace.isMutator) {
45+
const parent = workspace.options.parentWorkspace
46+
?.getAllBlocks()
47+
.map((block) => block.getIcons())
48+
.flat()
49+
.find(
50+
(icon): icon is icons.MutatorIcon =>
51+
icon instanceof icons.MutatorIcon &&
52+
icon.bubbleIsVisible() &&
53+
icon.getBubble()?.getWorkspace() === workspace,
54+
);
55+
if (parent) {
56+
parent.setBubbleVisible(false);
57+
getFocusManager().focusNode(parent);
58+
return true;
59+
}
60+
}
61+
return false;
62+
}
4263
default:
4364
return false;
4465
}

src/navigation.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -824,9 +824,19 @@ export class Navigation {
824824
* @returns whether keyboard navigation is currently allowed.
825825
*/
826826
canCurrentlyNavigate(workspace: Blockly.WorkspaceSvg) {
827-
const accessibilityMode = workspace.isFlyout
828-
? workspace.targetWorkspace?.keyboardAccessibilityMode
829-
: workspace.keyboardAccessibilityMode;
827+
// Only the main/root workspace has the accessibility mode bit set; for
828+
// nested workspaces (mutators or flyouts) we need to walk up the tree.
829+
// Default to the root workspace if present. Flyouts don't consider
830+
// their workspaces to have a root workspace/be a nested child, so fall
831+
// back to checking the target workspace's root (`.targetWorkspace` only
832+
// exists on flyout workspaces) and then fall back to the target/main
833+
// workspace itself.
834+
const accessibilityMode = (
835+
workspace.getRootWorkspace() ??
836+
workspace.targetWorkspace?.getRootWorkspace() ??
837+
workspace.targetWorkspace ??
838+
workspace
839+
).keyboardAccessibilityMode;
830840
return (
831841
!!accessibilityMode &&
832842
this.getState() !== Constants.STATE.NOWHERE &&
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as chai from 'chai';
8+
import * as Blockly from 'blockly';
9+
import {
10+
focusedTreeIsMainWorkspace,
11+
focusOnBlock,
12+
getCurrentFocusNodeId,
13+
getFocusedBlockType,
14+
testSetup,
15+
testFileLocations,
16+
PAUSE_TIME,
17+
tabNavigateToWorkspace,
18+
keyRight,
19+
keyDown,
20+
} from './test_setup.js';
21+
import {Key} from 'webdriverio';
22+
23+
suite('Mutator navigation', function () {
24+
// Setting timeout to unlimited as these tests take a longer time to run than most mocha test
25+
this.timeout(0);
26+
27+
// Setup Selenium for all of the tests
28+
setup(async function () {
29+
this.browser = await testSetup(testFileLocations.NAVIGATION_TEST_BLOCKS);
30+
this.openMutator = async () => {
31+
await tabNavigateToWorkspace(this.browser);
32+
await this.browser.pause(PAUSE_TIME);
33+
await focusOnBlock(this.browser, 'controls_if_1');
34+
await this.browser.pause(PAUSE_TIME);
35+
// Navigate to the mutator icon
36+
await keyRight(this.browser);
37+
// Activate the icon
38+
await this.browser.keys(Key.Enter);
39+
await this.browser.pause(PAUSE_TIME);
40+
};
41+
});
42+
43+
test('Enter opens mutator', async function () {
44+
await this.openMutator();
45+
46+
// Main workspace should not be focused (because mutator workspace is)
47+
const mainWorkspaceFocused = await focusedTreeIsMainWorkspace(this.browser);
48+
chai.assert.isFalse(mainWorkspaceFocused);
49+
50+
// The "if" placeholder block in the mutator should be focused
51+
const focusedBlockType = await getFocusedBlockType(this.browser);
52+
chai.assert.equal(focusedBlockType, 'controls_if_if');
53+
});
54+
55+
test('Escape dismisses mutator', async function () {
56+
await this.openMutator();
57+
await this.browser.keys(Key.Escape);
58+
await this.browser.pause(PAUSE_TIME);
59+
60+
// Main workspace should be the focused tree (since mutator workspace is gone)
61+
const mainWorkspaceFocused = await focusedTreeIsMainWorkspace(this.browser);
62+
chai.assert.isTrue(mainWorkspaceFocused);
63+
64+
const mutatorIconId = await this.browser.execute(() => {
65+
const block = Blockly.getMainWorkspace().getBlockById('controls_if_1');
66+
const icon = block?.getIcon(Blockly.icons.IconType.MUTATOR);
67+
return icon?.getFocusableElement().id;
68+
});
69+
70+
// Mutator icon should now be focused
71+
const focusedNodeId = await getCurrentFocusNodeId(this.browser);
72+
chai.assert.equal(mutatorIconId, focusedNodeId);
73+
});
74+
75+
test('Escape in the mutator flyout focuses the mutator workspace', async function () {
76+
await this.openMutator();
77+
// Focus the flyout
78+
await this.browser.keys('t');
79+
await this.browser.pause(PAUSE_TIME);
80+
// Hit escape to return focus to the mutator workspace
81+
await this.browser.keys(Key.Escape);
82+
await this.browser.pause(PAUSE_TIME);
83+
// The "if" placeholder block in the mutator should be focused
84+
const focusedBlockType = await getFocusedBlockType(this.browser);
85+
chai.assert.equal(focusedBlockType, 'controls_if_if');
86+
});
87+
88+
test('T focuses the mutator flyout', async function () {
89+
await this.openMutator();
90+
await this.browser.keys('t');
91+
await this.browser.pause(PAUSE_TIME);
92+
93+
// The "else if" block in the mutator flyout should be focused
94+
const focusedBlockType = await getFocusedBlockType(this.browser);
95+
chai.assert.equal(focusedBlockType, 'controls_if_elseif');
96+
});
97+
98+
test('Blocks can be inserted from the mutator flyout', async function () {
99+
await this.openMutator();
100+
await this.browser.keys('t');
101+
await this.browser.pause(PAUSE_TIME);
102+
// Navigate down to the second block in the flyout
103+
await keyDown(this.browser);
104+
await this.browser.pause(PAUSE_TIME);
105+
// Hit enter to enter insert mode
106+
await this.browser.keys(Key.Enter);
107+
await this.browser.pause(PAUSE_TIME);
108+
// Hit enter again to lock it into place on the connection
109+
await this.browser.keys(Key.Enter);
110+
111+
const topBlocks = await this.browser.execute(() => {
112+
const focusedTree = Blockly.getFocusManager().getFocusedTree();
113+
if (!(focusedTree instanceof Blockly.WorkspaceSvg)) {
114+
throw new Error('Focused tree is not a workspace.');
115+
}
116+
117+
return focusedTree.getAllBlocks(true).map((block) => block.type);
118+
});
119+
120+
chai.assert.deepEqual(topBlocks, ['controls_if_if', 'controls_if_else']);
121+
});
122+
});

test/webdriverio/test/test_setup.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,20 @@ export async function currentFocusIsMainWorkspace(
229229
});
230230
}
231231

232+
/**
233+
* Returns whether the currently focused tree is the main workspace.
234+
*
235+
* @param browser The active WebdriverIO Browser object.
236+
*/
237+
export async function focusedTreeIsMainWorkspace(
238+
browser: WebdriverIO.Browser,
239+
): Promise<boolean> {
240+
return await browser.execute(() => {
241+
const workspaceSvg = Blockly.getMainWorkspace() as Blockly.WorkspaceSvg;
242+
return Blockly.getFocusManager().getFocusedTree() === workspaceSvg;
243+
});
244+
}
245+
232246
/**
233247
* Focuses and selects a block with the provided ID.
234248
*

0 commit comments

Comments
 (0)