Skip to content

[Feature]: keyboard shortcuts to switch focus between widgets #3332

@andreasnyberg82

Description

@andreasnyberg82

Feature description

often have multiple widgets open (several terminals plus a browser) and I rely heavily on keyboard navigation. I’d like built‑in shortcuts to move focus between widgets (e.g., “next/previous widget” and maybe direct focus like “widget 1/2/3”).

Important details:
– I frequently change layouts (full screen, tiled, different monitors), so hacks based on fixed/relative screen coordinates aren’t reliable.
– I’m specifically looking for a layout‑independent, app‑native solution (like `Cmd+`` to cycle windows in one app), not global automation tools.

This would make Wave much more usable for keyboard‑driven workflows.

Implementation Suggestion

I don’t know Wave’s actual codebase, so I can’t give drop‑in code, but here’s a concrete, framework‑agnostic sketch that should be easy to adapt if you are using React + a central store (Redux/Zustand, etc.) and an Electron/desktop shell.

Data model (central store)


ts

// types
type WidgetId = string;

interface Widget {
id: WidgetId;
title: string;
// type: "terminal" | "browser" | "docs" | ...
// layout info, etc.
}

interface WidgetsState {
orderedIds: WidgetId[]; // visual order left‑to‑right / top‑to‑bottom
focusedId: WidgetId | null; // currently focused widget
mruStack: WidgetId[]; // [mostRecent, nextMostRecent, ...]
}
Reducers / actions
ts

// action creators
const focusWidget = (id: WidgetId) => ({ type: "FOCUS_WIDGET", id });
const focusNextWidget = () => ({ type: "FOCUS_NEXT_WIDGET" });
const focusPrevWidget = () => ({ type: "FOCUS_PREV_WIDGET" });
const focusLastWidget = () => ({ type: "FOCUS_LAST_WIDGET" }); // MRU toggle
const setWidgetOrder = (orderedIds: WidgetId[]) => ({
type: "SET_WIDGET_ORDER",
orderedIds,
});

// helpers
function updateMruStack(mru: WidgetId[], id: WidgetId): WidgetId[] {
// remove id if already present, then unshift
const filtered = mru.filter(wid => wid !== id);
filtered.unshift(id);
// optional: cap length
return filtered.slice(0, 16);
}

function focusByOffset(state: WidgetsState, delta: number): WidgetsState {
const { orderedIds, focusedId } = state;
if (orderedIds.length === 0) return state;

const currentIndex = focusedId
? orderedIds.indexOf(focusedId)
: 0;

const idx = ((currentIndex + delta) % orderedIds.length + orderedIds.length)
% orderedIds.length;

const nextId = orderedIds[idx];
return {
...state,
focusedId: nextId,
mruStack: updateMruStack(state.mruStack, nextId),
};
}

// reducer
function widgetsReducer(state: WidgetsState, action: any): WidgetsState {
switch (action.type) {
case "FOCUS_WIDGET": {
if (!state.orderedIds.includes(action.id)) return state;
return {
...state,
focusedId: action.id,
mruStack: updateMruStack(state.mruStack, action.id),
};
}

case "FOCUS_NEXT_WIDGET":
  return focusByOffset(state, +1);

case "FOCUS_PREV_WIDGET":
  return focusByOffset(state, -1);

case "FOCUS_LAST_WIDGET": {
  const [current, last] = state.mruStack;
  if (!last || last === current) return state;
  return {
    ...state,
    focusedId: last,
    mruStack: updateMruStack(state.mruStack, last),
  };
}

case "SET_WIDGET_ORDER": {
  const orderedIds: WidgetId[] = action.orderedIds;
  // keep focusedId if still present; otherwise pick first
  const focusedId =
    state.focusedId && orderedIds.includes(state.focusedId)
      ? state.focusedId
      : orderedIds[0] ?? null;

  // filter MRU to only still‑present ids
  const mruStack = state.mruStack.filter(id =>
    orderedIds.includes(id)
  );

  return {
    ...state,
    orderedIds,
    focusedId,
    mruStack: focusedId
      ? updateMruStack(mruStack, focusedId)
      : mruStack,
  };
}

default:
  return state;

}
}
Keyboard bindings (app shell)
At the Electron/window level, hook global shortcuts while the Wave window is focused:

ts

// pseudo‑code; adapt to actual key handling (Electron, React hotkeys, etc.)

registerShortcut("Ctrl+Tab", () => {
dispatch(focusNextWidget());
});

registerShortcut("Ctrl+Shift+Tab", () => {
dispatch(focusPrevWidget());
});

registerShortcut("Ctrl+`", () => {
dispatch(focusLastWidget()); // MRU toggle
});
Applying focus in the UI
Each widget container subscribes to focusedId and sets DOM focus when it becomes active:

tsx

function WidgetContainer({ widget }: { widget: Widget }) {
const focusedId = useSelector(s => s.widgets.focusedId);
const dispatch = useDispatch();
const ref = useRef<HTMLDivElement | null>(null);

const isFocused = focusedId === widget.id;

useEffect(() => {
if (isFocused && ref.current) {
ref.current.focus(); // or focus inner terminal/browser view
}
}, [isFocused]);

return (
<div
ref={ref}
tabIndex={0}
onMouseDown={() => dispatch(focusWidget(widget.id))}
className={isFocused ? "widget focused" : "widget"}
>
{/* widget content */}

);
}
Maintaining orderedIds
Wherever the layout engine already computes the visual ordering of widgets (e.g., when you:

create/close a widget
split/merge panes
drag‑reorder),
make sure it calls setWidgetOrder(newOrderedIds) with the current visible order (left‑to‑right / top‑to‑bottom). No coordinate hacks are needed; this is purely internal, layout‑aware ordering.

Behavior this gives:

Ctrl+Tab / Ctrl+Shift+Tab: cycle focus through widgets in the current layout order (independent of window size/monitor).
`Ctrl+``: toggle between the last two focused widgets (MRU stack).
Mouse clicks still move focus and feed the MRU stack.
Layout changes keep the current focus if possible; otherwise fall back gracefully.
You can extend the same store to add “focus widget N” (Alt+1…9) by indexing into orderedIds[N-1] and dispatching focusWidget.

Anything else?

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesttriageNeeds triage

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions