Skip to content

Commit b9067fb

Browse files
authored
Add app:focusfollowscursor setting (off/on/term) for hover-based block focus (#2908)
This PR adds a new app setting to control whether block focus follows cursor movement: - `app:focusfollowscursor: "off" | "on" | "term"` - Default is `"off"` (no behavior change unless enabled) ## What changed - Added hover-focus behavior on block pointer enter. - Added guardrails so hover focus does not trigger when: - touch input is used - pointer buttons are pressed (drag/select scenarios) - modal is open - pointer events are disabled - block is resizing - Added config key plumbing across settings types and schema: - `pkg/wconfig/settingsconfig.go` - `pkg/wconfig/metaconsts.go` - `schema/settings.json` - `pkg/wconfig/defaultconfig/settings.json` -> `"app:focusfollowscursor": "off"` - Added docs for the new key and default example: - `docs/docs/config.mdx` ## Behavior - `"off"`: do not focus on cursor movement - `"on"`: focus follows cursor for all block types - `"term"`: focus follows cursor only for terminal blocks
1 parent cc0f5c7 commit b9067fb

11 files changed

Lines changed: 71 additions & 6 deletions

File tree

docs/docs/config.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ wsh editconfig
4343
| app:hideaibutton <VersionBadge version="v0.14" /> | bool | Set to true to hide the AI button in the tab bar (defaults to false) |
4444
| app:disablectrlshiftarrows <VersionBadge version="v0.14" /> | bool | Set to true to disable Ctrl+Shift block-navigation keybindings (`Arrow` and `h/j/k/l`) (defaults to false) |
4545
| app:disablectrlshiftdisplay <VersionBadge version="v0.14" /> | bool | Set to true to disable the Ctrl+Shift visual indicator display (defaults to false) |
46+
| app:focusfollowscursor <VersionBadge version="v0.14" /> | string | Controls whether block focus follows cursor movement: `"off"` (default), `"on"` (all blocks), or `"term"` (terminal blocks only) |
4647
| ai:preset | string | the default AI preset to use |
4748
| ai:baseurl | string | Set the AI Base Url (must be OpenAI compatible) |
4849
| ai:apitoken | string | your AI api token |
@@ -122,6 +123,7 @@ For reference, this is the current default configuration (v0.14.0):
122123
"app:hideaibutton": false,
123124
"app:disablectrlshiftarrows": false,
124125
"app:disablectrlshiftdisplay": false,
126+
"app:focusfollowscursor": "off",
125127
"autoupdate:enabled": true,
126128
"autoupdate:installonquit": true,
127129
"autoupdate:intervalms": 3600000,

frontend/app/block/block.tsx

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import { CenteredDiv } from "@/element/quickelems";
2020
import { useDebouncedNodeInnerRect } from "@/layout/index";
2121
import { counterInc } from "@/store/counters";
2222
import {
23+
atoms,
2324
getBlockComponentModel,
25+
getSettingsKeyAtom,
2426
registerBlockComponentModel,
2527
unregisterBlockComponentModel,
2628
} from "@/store/global";
@@ -147,6 +149,11 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {
147149
const [blockData] = useWaveObjectValue<Block>(makeORef("block", nodeModel.blockId));
148150
const isFocused = useAtomValue(nodeModel.isFocused);
149151
const disablePointerEvents = useAtomValue(nodeModel.disablePointerEvents);
152+
const isResizing = useAtomValue(nodeModel.isResizing);
153+
const isMagnified = useAtomValue(nodeModel.isMagnified);
154+
const anyMagnified = useAtomValue(nodeModel.anyMagnified);
155+
const modalOpen = useAtomValue(atoms.modalOpen);
156+
const focusFollowsCursorMode = useAtomValue(getSettingsKeyAtom("app:focusfollowscursor")) ?? "off";
150157
const innerRect = useDebouncedNodeInnerRect(nodeModel);
151158
const noPadding = useAtomValueSafe(viewModel.noPadding);
152159

@@ -220,13 +227,50 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {
220227
return;
221228
}
222229
focusElemRef.current?.focus({ preventScroll: true });
223-
}, []);
230+
}, [viewModel]);
224231

225-
const blockModel: BlockComponentModel2 = {
226-
onClick: setBlockClickedTrue,
227-
onFocusCapture: handleChildFocus,
228-
blockRef: blockRef,
229-
};
232+
const focusFromPointerEnter = useCallback(
233+
(event: React.PointerEvent<HTMLDivElement>) => {
234+
const focusFollowsCursorEnabled =
235+
focusFollowsCursorMode === "on" ||
236+
(focusFollowsCursorMode === "term" && blockData?.meta?.view === "term");
237+
if (!focusFollowsCursorEnabled || event.pointerType === "touch" || event.buttons > 0) {
238+
return;
239+
}
240+
if (modalOpen || disablePointerEvents || isResizing || (anyMagnified && !isMagnified)) {
241+
return;
242+
}
243+
if (isFocused && focusedBlockId() === nodeModel.blockId) {
244+
return;
245+
}
246+
setFocusTarget();
247+
if (!isFocused) {
248+
nodeModel.focusNode();
249+
}
250+
},
251+
[
252+
focusFollowsCursorMode,
253+
blockData?.meta?.view,
254+
modalOpen,
255+
disablePointerEvents,
256+
isResizing,
257+
isMagnified,
258+
anyMagnified,
259+
isFocused,
260+
nodeModel,
261+
setFocusTarget,
262+
]
263+
);
264+
265+
const blockModel = useMemo<BlockComponentModel2>(
266+
() => ({
267+
onClick: setBlockClickedTrue,
268+
onPointerEnter: focusFromPointerEnter,
269+
onFocusCapture: handleChildFocus,
270+
blockRef: blockRef,
271+
}),
272+
[setBlockClickedTrue, focusFromPointerEnter, handleChildFocus, blockRef]
273+
);
230274

231275
return (
232276
<BlockFrame

frontend/app/block/blockframe.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
163163
})}
164164
data-blockid={nodeModel.blockId}
165165
onClick={blockModel?.onClick}
166+
onPointerEnter={blockModel?.onPointerEnter}
166167
onFocusCapture={blockModel?.onFocusCapture}
167168
ref={blockModel?.blockRef}
168169
style={

frontend/app/block/blocktypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface SubBlockProps {
3535

3636
export interface BlockComponentModel2 {
3737
onClick?: () => void;
38+
onPointerEnter?: React.PointerEventHandler<HTMLDivElement>;
3839
onFocusCapture?: React.FocusEventHandler<HTMLDivElement>;
3940
blockRef?: React.RefObject<HTMLDivElement>;
4041
}

frontend/layout/lib/layoutModel.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,10 @@ export class LayoutModel {
10731073
const treeState = get(this.localTreeStateAtom);
10741074
return treeState.magnifiedNodeId === nodeid;
10751075
}),
1076+
anyMagnified: atom((get) => {
1077+
const treeState = get(this.localTreeStateAtom);
1078+
return treeState.magnifiedNodeId != null;
1079+
}),
10761080
isEphemeral: atom((get) => {
10771081
const ephemeralNode = get(this.ephemeralNode);
10781082
return ephemeralNode?.id === nodeid;

frontend/layout/lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ export interface NodeModel {
391391
isResizing: Atom<boolean>;
392392
isFocused: Atom<boolean>;
393393
isMagnified: Atom<boolean>;
394+
anyMagnified: Atom<boolean>;
394395
isEphemeral: Atom<boolean>;
395396
ready: Atom<boolean>;
396397
disablePointerEvents: Atom<boolean>;

frontend/types/gotypes.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1256,6 +1256,7 @@ declare global {
12561256
"app:hideaibutton"?: boolean;
12571257
"app:disablectrlshiftarrows"?: boolean;
12581258
"app:disablectrlshiftdisplay"?: boolean;
1259+
"app:focusfollowscursor"?: string;
12591260
"feature:waveappbuilder"?: boolean;
12601261
"ai:*"?: boolean;
12611262
"ai:preset"?: string;

pkg/wconfig/defaultconfig/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"app:hideaibutton": false,
99
"app:disablectrlshiftarrows": false,
1010
"app:disablectrlshiftdisplay": false,
11+
"app:focusfollowscursor": "off",
1112
"autoupdate:enabled": true,
1213
"autoupdate:installonquit": true,
1314
"autoupdate:intervalms": 3600000,

pkg/wconfig/metaconsts.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const (
1616
ConfigKey_AppHideAiButton = "app:hideaibutton"
1717
ConfigKey_AppDisableCtrlShiftArrows = "app:disablectrlshiftarrows"
1818
ConfigKey_AppDisableCtrlShiftDisplay = "app:disablectrlshiftdisplay"
19+
ConfigKey_AppFocusFollowsCursor = "app:focusfollowscursor"
1920

2021
ConfigKey_FeatureWaveAppBuilder = "feature:waveappbuilder"
2122

pkg/wconfig/settingsconfig.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ type SettingsType struct {
6363
AppHideAiButton bool `json:"app:hideaibutton,omitempty"`
6464
AppDisableCtrlShiftArrows bool `json:"app:disablectrlshiftarrows,omitempty"`
6565
AppDisableCtrlShiftDisplay bool `json:"app:disablectrlshiftdisplay,omitempty"`
66+
AppFocusFollowsCursor string `json:"app:focusfollowscursor,omitempty" jsonschema:"enum=off,enum=on,enum=term"`
6667

6768
FeatureWaveAppBuilder bool `json:"feature:waveappbuilder,omitempty"`
6869

0 commit comments

Comments
 (0)