diff --git a/src/workbench/MainMenu.test.ts b/src/workbench/MainMenu.test.ts index 55cd4bf3b..e8b52d985 100644 --- a/src/workbench/MainMenu.test.ts +++ b/src/workbench/MainMenu.test.ts @@ -1,5 +1,56 @@ import { describe, expect, it } from "vitest"; -import { menuItems } from "./MainMenu"; +import { menuItems, onLoneAlt } from "./MainMenu"; + +describe("lone Alt opens the main menu", () => { + const key = (type: string, k: string, init: KeyboardEventInit = {}) => + window.dispatchEvent(new KeyboardEvent(type, { key: k, bubbles: true, cancelable: true, ...init })); + it.each<[string, () => void, number]>([ + ["Alt alone", () => (key("keydown", "Alt", { altKey: true }), key("keyup", "Alt")), 1], + [ + "Alt+1", + () => ( + key("keydown", "Alt", { altKey: true }), + key("keydown", "1", { altKey: true }), + key("keyup", "1"), + key("keyup", "Alt") + ), + 0, + ], + [ + "Ctrl+Alt", + () => ( + key("keydown", "Control", { ctrlKey: true }), + key("keydown", "Alt", { ctrlKey: true, altKey: true }), + key("keyup", "Alt") + ), + 0, + ], + [ + "Alt+click", + () => ( + key("keydown", "Alt", { altKey: true }), + window.dispatchEvent(new Event("pointerdown")), + key("keyup", "Alt") + ), + 0, + ], + [ + "held Alt (repeat)", + () => ( + key("keydown", "Alt", { altKey: true }), + key("keydown", "Alt", { altKey: true, repeat: true }), + key("keyup", "Alt") + ), + 0, + ], + ])("%s", (_, press, expected) => { + let opened = 0; + const off = onLoneAlt(window, () => void opened++); + press(); + off(); + expect(opened).toBe(expected); + }); +}); describe("main menu model", () => { it("maps menu nodes to menu items, localising spec labels only", () => { @@ -11,7 +62,13 @@ describe("main menu model", () => { id: "openRecent", label: "Open Recent", children: [ - { kind: "action", id: "file.openRecentProject", label: "openidea — ~/openidea", hint: null, arg: "/p/openidea" }, + { + kind: "action", + id: "file.openRecentProject", + label: "openidea — ~/openidea", + hint: null, + arg: "/p/openidea", + }, { kind: "placeholder", id: "recent.none", label: "No Recent Projects" }, ], }, diff --git a/src/workbench/MainMenu.tsx b/src/workbench/MainMenu.tsx index 55fcef048..8dc9061fa 100644 --- a/src/workbench/MainMenu.tsx +++ b/src/workbench/MainMenu.tsx @@ -2,9 +2,10 @@ * Windows title bar: IDEA's main menu button. Clicking ☰ expands the menu bar * in place (File Edit View …) with File open; hovering another title switches * menus, ←/→ walk them, and closing the menu collapses the bar again. The - * entries come from the same spec as the native menu (`menu_model`). + * entries come from the same spec as the native menu (`menu_model`). As in + * Windows apps and IDEA, pressing and releasing Alt alone opens it too. */ -import { useRef, useState, type KeyboardEvent } from "react"; +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import { lookup, t } from "@/platform/i18n"; import { commands, type MenuNode } from "@/platform/ipc"; @@ -38,6 +39,37 @@ export function menuItems(nodes: MenuNode[]): MenuItem[] { }); } +/** + * Calls `onAlt` when Alt is pressed and released with nothing else in between + * (no other key, no click): Alt+1, Alt+Enter or Alt+click never open the menu. + */ +export function onLoneAlt(target: Window, onAlt: () => void): () => void { + let armed = false; + const down = (e: globalThis.KeyboardEvent) => { + armed = e.key === "Alt" && !e.repeat && !e.ctrlKey && !e.shiftKey && !e.metaKey; + }; + const up = (e: globalThis.KeyboardEvent) => { + const fire = armed && e.key === "Alt"; + armed = false; + if (!fire) return; + e.preventDefault(); + onAlt(); + }; + const cancel = () => { + armed = false; + }; + target.addEventListener("keydown", down, true); + target.addEventListener("keyup", up, true); + target.addEventListener("pointerdown", cancel, true); + target.addEventListener("blur", cancel); + return () => { + target.removeEventListener("keydown", down, true); + target.removeEventListener("keyup", up, true); + target.removeEventListener("pointerdown", cancel, true); + target.removeEventListener("blur", cancel); + }; +} + export function MainMenu({ onExpandedChange }: { onExpandedChange?: (expanded: boolean) => void }) { const [menus, setMenus] = useState(null); const [open, setOpen] = useState(null); @@ -65,6 +97,7 @@ export function MainMenu({ onExpandedChange }: { onExpandedChange?: (expanded: b else collapse(); }); }; + useEffect(() => onLoneAlt(window, () => (menus ? collapse() : void expand()))); const step = (delta: number) => { if (!menus || open === null) return; setOpen((open + delta + menus.length) % menus.length); diff --git a/src/workbench/TitleBar.test.tsx b/src/workbench/TitleBar.test.tsx index eccac34b0..eb99647a4 100644 --- a/src/workbench/TitleBar.test.tsx +++ b/src/workbench/TitleBar.test.tsx @@ -1,6 +1,9 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./WindowControls", () => ({ WindowControls: () => null })); import { fireEvent, render, screen } from "@testing-library/react"; import { registerActions } from "@/platform/actions"; +import { usePlatform } from "@/platform/platformInfo"; import { useThemeOverride } from "./theme"; import { customizeToolbarItems, gearMenuItems, TitleBar } from "./TitleBar"; @@ -44,11 +47,15 @@ describe("title bar gear menu", () => { const items = customizeToolbarItems( { left: ["project", "vcs.branch"], right: ["action:navigate.searchEverywhere"] }, ["run.widget"], - new Map([["run.widget", { id: "run.widget", align: "right", priority: 1, component: () => null, title: "Run Widget" }]]), + new Map([ + ["run.widget", { id: "run.widget", align: "right", priority: 1, component: () => null, title: "Run Widget" }], + ]), (id) => toggled.push(id), () => void reset++, ); - const rows = items.filter((i): i is { id: string; label: string; checked: boolean; run: () => void } => "checked" in i); + const rows = items.filter( + (i): i is { id: string; label: string; checked: boolean; run: () => void } => "checked" in i, + ); expect(rows.map((r) => [r.label, r.checked])).toEqual([ ["Project Widget", true], ["VCS Widget", true], @@ -75,3 +82,24 @@ describe("title bar gear menu", () => { expect(useThemeOverride.getState().theme).toBe("dark-hc"); }); }); + +describe("title bar in Distraction Free Mode (bare)", () => { + // The main menu is the way back out of a view mode that hides the toolbar: + // the title bar's ☰ where the window is frameless (Windows), the OS menu bar elsewhere. + it.each([ + { os: "windows", overlayTitleBar: false, framelessTitleBar: true, mainMenu: true }, + { os: "macos", overlayTitleBar: true, framelessTitleBar: false, mainMenu: false }, + { os: "linux", overlayTitleBar: false, framelessTitleBar: false, mainMenu: false }, + ])("$os: keeps the main menu button where there is no native menu bar", ({ mainMenu, ...platform }) => { + const saved = usePlatform.getState(); + usePlatform.setState(platform); + try { + const { container } = render(); + expect(container.querySelector("header[data-titlebar='bare']")).not.toBeNull(); + expect(screen.queryByRole("button", { name: "IDE and Project Settings" })).toBeNull(); + expect(!!screen.queryByRole("button", { name: "Main Menu" })).toBe(mainMenu); + } finally { + usePlatform.setState(saved); + } + }); +}); diff --git a/src/workbench/TitleBar.tsx b/src/workbench/TitleBar.tsx index 86f4b70b1..21266e1db 100644 --- a/src/workbench/TitleBar.tsx +++ b/src/workbench/TitleBar.tsx @@ -275,11 +275,14 @@ export function TitleBar({ const { map, shown, menu } = useToolbar(); const [menuExpanded, setMenuExpanded] = useState(false); const className = clsx(styles.bar, overlay && styles.overlay, frameless && styles.frameless); + // Distraction Free Mode: the window frame only. Where the title bar carries the + // main menu (Windows) it stays, or nothing would lead back out of the mode. if (bare) return (
{frameless && ( <> +
diff --git a/src/workbench/distractionFree.test.tsx b/src/workbench/distractionFree.test.tsx index 88bd3be49..8d85c547f 100644 --- a/src/workbench/distractionFree.test.tsx +++ b/src/workbench/distractionFree.test.tsx @@ -5,8 +5,12 @@ import { getContext } from "@/platform/context"; import { bindingsFor, installKeyboardDispatcher, useKeymap } from "@/platform/keymap"; import { useProject } from "@/platform/project"; import { registerStatusItem, registerToolWindow } from "./contributions"; +import { clearNotifications, useNotifications } from "./notificationStore"; +import { notificationActions } from "./Notifications"; import { DISTRACTION_FREE_ACTION, + DISTRACTION_FREE_HINT_KEY, + distractionFreeHint, distractionFreeWidth, installDistractionFree, isDistractionFree, @@ -127,6 +131,62 @@ describe("distraction free mode", () => { setDistractionFree(false); }); + describe("exit hint", () => { + const hint = () => useNotifications.getState().items.find((n) => n.key === DISTRACTION_FREE_HINT_KEY); + beforeEach(() => clearNotifications()); + + it("tells how to leave the mode on entering, with an Exit action, and expires on exit", () => { + setDistractionFree(true); + expect(hint()?.balloon).toBe(true); + expect(hint()?.body).toBe(distractionFreeHint()); + expect(distractionFreeHint()).toContain("View | Appearance | Exit Distraction Free Mode"); + // Unbound toggle (IntelliJ keymap): Find Action with its shortcut is the keyboard way out. + expect(distractionFreeHint()).toMatch(/Find Action \(.+\)/); + hint()! + .actions!.find((a) => a.label === "Exit Distraction Free Mode")! + .run(); + expect(isDistractionFree()).toBe(false); + expect(hint()?.balloon ?? false).toBe(false); + }); + + it("names the toggle's own shortcut when it has one", () => { + useKeymap.setState({ overrides: { [DISTRACTION_FREE_ACTION]: ["Ctrl+Alt+Z"] } }); + expect(distractionFreeHint()).not.toContain("Find Action"); + }); + + it("shows again after a restart in the mode, once the project frame loads", () => { + setDistractionFree(true); + clearNotifications(); + useDistractionFree.setState({ active: false }); + resetToolWindowLayout(); + disposers.push(installDistractionFree()); + expect(isDistractionFree()).toBe(true); + expect(hint()).toBeUndefined(); + loadToolWindowLayout("/work/df"); + expect(hint()?.balloon).toBe(true); + }); + + it("offers Don't Show Again once, scoped to its own notification group", () => { + setDistractionFree(true); + const labels = notificationActions(hint()!).map((a) => a.label); + expect(labels.filter((l) => l === "Don't Show Again")).toHaveLength(1); + expect(hint()!.group).toBe("distractionFree"); + }); + + it("stays off once its group shows no popup", () => { + const settings = useProject.getState().settings; + useProject.setState({ + settings: { ...settings, notifications: { groups: { distractionFree: { display: "none" } } } } as never, + }); + try { + setDistractionFree(true); + expect(hint()?.balloon ?? false).toBe(false); + } finally { + useProject.setState({ settings }); + } + }); + }); + it("clamps the text width setting", () => { expect(distractionFreeWidth(undefined)).toBe(80); expect(distractionFreeWidth(100.4)).toBe(100); @@ -138,7 +198,9 @@ describe("distraction free mode", () => { describe("distraction free shell", () => { it("hides stripes, status bar and main toolbar contents, keeping the drag region", async () => { const { Workbench } = await import("./Workbench"); - disposers.push(registerStatusItem({ id: "t.status", align: "left", priority: 0, component: () => status-x })); + disposers.push( + registerStatusItem({ id: "t.status", align: "left", priority: 0, component: () => status-x }), + ); act(() => useProject.setState({ project: { root: "/work/df", name: "df-demo", isGit: false, settings: {} } })); const { unmount } = render(); expect(await screen.findByText("df-demo")).toBeInTheDocument(); diff --git a/src/workbench/distractionFree.ts b/src/workbench/distractionFree.ts index e34bd0230..74aaf91e1 100644 --- a/src/workbench/distractionFree.ts +++ b/src/workbench/distractionFree.ts @@ -7,11 +7,14 @@ * application-wide preference that survives restarts (localStorage, next to * the saved tool window layouts, which keep the layout to restore on exit). * Published as the `distractionFree` context key; components read - * `useDistractionFree`. + * `useDistractionFree`. Entering (or starting in) the mode shows IDEA's hint + * on how to leave it, since the main toolbar and status bar are gone. */ import { create } from "zustand"; import { registerAction, type Action } from "@/platform/actions"; import { setContext } from "@/platform/context"; +import { shortcutLabel } from "@/platform/keymap"; +import { expireNotification, notify } from "./notificationStore"; import { enterDistractionFreeLayout, exitDistractionFreeLayout, useToolWindowLayout } from "./toolwindows/store"; /** Kept from the original id so keymaps (VS Code's ⌘K Z, user bindings) stay bound. */ @@ -19,6 +22,7 @@ export const DISTRACTION_FREE_ACTION = "editor.toggleZen"; export const DISTRACTION_FREE_CONTEXT = "distractionFree"; export const DEFAULT_DISTRACTION_FREE_WIDTH = 80; const STORAGE_KEY = "openidea.distractionFree"; +export const DISTRACTION_FREE_HINT_KEY = "distractionFree.hint"; export const useDistractionFree = create<{ active: boolean }>(() => ({ active: false })); @@ -28,7 +32,9 @@ export function isDistractionFree(): boolean { /** Text width in columns (Settings › Editor › General › Appearance), clamped like IDEA's field. */ export function distractionFreeWidth(raw: unknown): number { - return typeof raw === "number" && Number.isFinite(raw) ? Math.min(500, Math.max(20, Math.round(raw))) : DEFAULT_DISTRACTION_FREE_WIDTH; + return typeof raw === "number" && Number.isFinite(raw) + ? Math.min(500, Math.max(20, Math.round(raw))) + : DEFAULT_DISTRACTION_FREE_WIDTH; } export function distractionFreeTitle(active: boolean): string { @@ -52,13 +58,41 @@ function readStored(): boolean { } } +/** IDEA's "To exit Distraction Free Mode, use View | Appearance or ". */ +export function distractionFreeHint(): string { + const own = shortcutLabel(DISTRACTION_FREE_ACTION); + const find = shortcutLabel("navigate.findAction"); + const way = own ?? (find ? `Find Action (${find})` : "Find Action"); + return `To exit Distraction Free Mode, use View | Appearance | Exit Distraction Free Mode or ${way}.`; +} + +/** Set on entering the mode; the hint shows once a project frame is there to leave. */ +let hintPending = false; + +function showHint() { + if (!hintPending || !isDistractionFree() || !useToolWindowLayout.getState().scope) return; + hintPending = false; + notify({ + key: DISTRACTION_FREE_HINT_KEY, + // Its own group: the balloon's "Don't Show Again" turns off this hint only (Settings › Notifications). + group: "distractionFree", + level: "info", + title: "Distraction Free Mode", + body: distractionFreeHint(), + actions: [{ label: "Exit Distraction Free Mode", run: () => setDistractionFree(false) }], + }); +} + export function setDistractionFree(active: boolean) { if (active === isDistractionFree()) return; useDistractionFree.setState({ active }); setContext(DISTRACTION_FREE_CONTEXT, active); writeStored(active); + hintPending = active; if (active) enterDistractionFreeLayout(); else exitDistractionFreeLayout(); + if (active) showHint(); + else expireNotification(DISTRACTION_FREE_HINT_KEY); } export function toggleDistractionFree() { @@ -89,7 +123,9 @@ export function installDistractionFree(): () => void { previous(); }); const unsubLayout = useToolWindowLayout.subscribe((s, prev) => { - if (s.scope !== prev.scope && isDistractionFree()) enterDistractionFreeLayout(); + if (s.scope === prev.scope || !isDistractionFree()) return; + enterDistractionFreeLayout(); + showHint(); }); setContext(DISTRACTION_FREE_CONTEXT, isDistractionFree()); if (readStored()) setDistractionFree(true); @@ -102,6 +138,7 @@ export function installDistractionFree(): () => void { /** Test helper. */ export function resetDistractionFree() { + hintPending = false; useDistractionFree.setState({ active: false }); setContext(DISTRACTION_FREE_CONTEXT, false); writeStored(false); diff --git a/src/workbench/notificationStore.ts b/src/workbench/notificationStore.ts index cdb966ab3..f67a384f5 100644 --- a/src/workbench/notificationStore.ts +++ b/src/workbench/notificationStore.ts @@ -34,6 +34,7 @@ export const NOTIFICATION_GROUPS: ReadonlyArray<{ id: string; title: string }> = { id: "search", title: "Find in Files" }, { id: "plugins", title: "Plugins" }, { id: "updates", title: "IDE Updates" }, + { id: "distractionFree", title: "Distraction Free Mode" }, ]; export type NotificationDisplay = "balloon" | "sticky" | "none";