feat(workbench): add merged main menu style

This commit is contained in:
l0ng-ai committed 2026-10-31 16:54:22 +08:00
1 parent 4796265b5c
commit f566efb91a
10 files changed
+514 -59

No files matched your search

+12
View File
@@ -285,6 +285,8 @@ pub struct AppearanceSettings {
pub project_colors_in_toolbar: bool,
pub dnd_with_alt_only: bool,
pub menu_icons: bool,
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
pub main_menu_style: String,
/// `subpixel` | `greyscale` | `none`
pub ide_antialiasing: String,
pub editor_antialiasing: String,
@@ -331,6 +333,7 @@ impl Default for AppearanceSettings {
project_colors_in_toolbar: true,
dnd_with_alt_only: false,
menu_icons: true,
main_menu_style: "hamburger".into(),
ide_antialiasing: "greyscale".into(),
editor_antialiasing: "greyscale".into(),
show_tool_window_bars: true,
@@ -713,6 +716,15 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
assert_eq!(old.appearance.main_menu_style, "hamburger");
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
assert_eq!(merged.appearance.main_menu_style, "merged");
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
}
#[test]
fn editor_font_weights_default_to_regular_and_bold() {
let json = serde_json::to_value(Settings::default()).unwrap();
+2
View File
@@ -1407,6 +1407,8 @@ export type AppearanceSettings = {
projectColorsInToolbar?: boolean,
dndWithAltOnly?: boolean,
menuIcons?: boolean,
/** `hamburger` | `merged`: main menu presentation in the frameless title bar. */
mainMenuStyle?: string,
/** `subpixel` | `greyscale` | `none` */
ideAntialiasing?: string,
editorAntialiasing?: string,
@@ -0,0 +1,46 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { usePlatform } from "@/platform/platformInfo";
import { validateAppearanceExtras } from "../model/behaviorSchema";
import { useSettingsDialog } from "../model/staging";
import { BEHAVIOR_PAGES } from "../pages/behavior";
const savedPlatform = usePlatform.getState();
const savedDialog = useSettingsDialog.getState();
afterEach(() => {
usePlatform.setState(savedPlatform);
useSettingsDialog.setState(savedDialog);
});
const option = BEHAVIOR_PAGES.find((p) => p.id === "appearance")!
.sections!.find((s) => s.title === "UI Options")!
.options.find((o) => o.kind === "custom")!;
if (option.kind !== "custom") throw new Error("UI Options missing");
const MainMenuStyleRow = option.component;
describe("Main menu appearance setting", () => {
it.each(["windows", "linux"])("offers both styles on a frameless %s title bar", async (os) => {
usePlatform.setState({ os, framelessTitleBar: true });
render(<MainMenuStyleRow />);
const select = screen.getByRole("button", { name: "Main menu" });
expect(select).toHaveTextContent("Hamburger button");
fireEvent.click(select);
fireEvent.click(await screen.findByRole("option", { name: "Merged with main toolbar" }));
expect(select).toHaveTextContent("Merged with main toolbar");
expect(useSettingsDialog.getState().staged["global|appearance.mainMenuStyle"]?.value).toBe("merged");
});
it.each(["macos", "web", "linux"])("hides the setting without the frameless menu (%s)", (os) => {
usePlatform.setState({ os, framelessTitleBar: false });
render(<MainMenuStyleRow />);
expect(screen.queryByText("Main menu")).toBeNull();
expect(screen.queryByRole("button", { name: "Main menu" })).toBeNull();
act(() => usePlatform.setState({ os: "windows", framelessTitleBar: true }));
expect(screen.getByRole("button", { name: "Main menu" })).toBeInTheDocument();
});
it("preserves the hamburger default for existing or invalid settings", () => {
expect(validateAppearanceExtras({}).mainMenuStyle).toBe("hamburger");
expect(validateAppearanceExtras({ mainMenuStyle: "unknown" }).mainMenuStyle).toBe("hamburger");
expect(validateAppearanceExtras({ mainMenuStyle: "merged" }).mainMenuStyle).toBe("merged");
});
});
@@ -27,6 +27,7 @@ export interface AppearanceExtras {
projectColorsInToolbar: boolean;
dndWithAltOnly: boolean;
menuIcons: boolean;
mainMenuStyle: "hamburger" | "merged";
ideAntialiasing: Antialiasing;
editorAntialiasing: Antialiasing;
showToolWindowBars: boolean;
@@ -78,6 +79,7 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = {
projectColorsInToolbar: true,
dndWithAltOnly: false,
menuIcons: true,
mainMenuStyle: "hamburger",
ideAntialiasing: "greyscale",
editorAntialiasing: "greyscale",
showToolWindowBars: true,
@@ -127,6 +129,7 @@ export function validateAppearanceExtras(raw: unknown): AppearanceExtras {
projectColorsInToolbar: b("projectColorsInToolbar"),
dndWithAltOnly: b("dndWithAltOnly"),
menuIcons: b("menuIcons"),
mainMenuStyle: oneOf(a.mainMenuStyle, ["hamburger", "merged"], d.mainMenuStyle),
ideAntialiasing: oneOf(a.ideAntialiasing, AA, d.ideAntialiasing),
editorAntialiasing: oneOf(a.editorAntialiasing, AA, d.editorAntialiasing),
showToolWindowBars: b("showToolWindowBars"),
+26
View File
@@ -9,6 +9,7 @@ import { FontPicker } from "../components/FontPicker";
import { useId, useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import { shortcutLabel } from "@/platform/keymap";
import { usePlatform } from "@/platform/platformInfo";
import { isTauri } from "@/platform/ipc";
import { SCHEMES as EDITOR_SCHEMES, listSchemes, customSchemes } from "@/platform/colorScheme";
import { formatTimestamp } from "@/platform/dateFormat";
@@ -223,6 +224,25 @@ function Grid({ left, right }: { left: ReactNode; right: ReactNode }) {
);
}
function MainMenuStyleRow() {
const frameless = usePlatform((s) => s.framelessTitleBar);
const [mainMenuStyle, setMainMenuStyle] = useSetting<"hamburger" | "merged">("appearance.mainMenuStyle");
if (!frameless) return null;
return (
<Row label="Main menu">
<Select
value={mainMenuStyle ?? "hamburger"}
options={[
{ value: "hamburger", label: "Hamburger button" },
{ value: "merged", label: "Merged with main toolbar" },
]}
onChange={setMainMenuStyle}
aria-label="Main menu"
/>
</Row>
);
}
function UiOptionsGrid() {
return (
<Grid
@@ -521,6 +541,12 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
{
title: "UI Options",
options: [
{
kind: "custom",
label: "Main menu",
keywords: ["Hamburger button", "Merged with main toolbar"],
component: MainMenuStyleRow,
},
{
kind: "custom",
label: "Compact mode",
+3
View File
@@ -2189,6 +2189,9 @@
"TypeScript version": "TypeScript 版本",
"Types": "类型",
"Typography Settings": "排版设置",
"Main menu": "主菜单",
"Hamburger button": "汉堡菜单按钮",
"Merged with main toolbar": "与主工具栏合并",
"UI Options": "UI 选项",
"UI elements take up less screen space": "UI 元素占用更少的屏幕空间",
"UI font": "UI 字体",
+201
View File
@@ -0,0 +1,201 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { commands, type Settings } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { registerActions } from "@/platform/actions";
import { MainMenu } from "./MainMenu";
let available = 500;
let resize: () => void;
const savedProject = useProject.getState();
let dispose: () => void;
const setStyle = (style: string) =>
useProject.setState({ settings: { appearance: { mainMenuStyle: style } } as Settings });
const alt = () => {
fireEvent.keyDown(window, { key: "Alt", altKey: true });
fireEvent.keyUp(window, { key: "Alt" });
};
beforeEach(() => {
available = 500;
vi.stubGlobal(
"ResizeObserver",
class {
constructor(private callback: () => void) {}
observe(element: HTMLElement) {
if (element.className.includes("mainMenuSlot")) resize = this.callback;
}
disconnect() {}
},
);
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(() => available);
vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockReturnValue(300);
vi.spyOn(commands, "menuModel").mockResolvedValue([
{
kind: "submenu",
id: "file",
label: "File",
children: [{ kind: "action", id: "test.open", label: "Open", hint: null, arg: null }],
},
{
kind: "submenu",
id: "edit",
label: "Edit",
children: [{ kind: "action", id: "test.undo", label: "Undo", hint: null, arg: null }],
},
]);
dispose = registerActions([
{ id: "test.open", title: "Open", category: "Test", run: vi.fn() },
{ id: "test.undo", title: "Undo", category: "Test", run: vi.fn() },
]);
setStyle("hamburger");
});
afterEach(() => {
dispose();
useProject.setState(savedProject);
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
const titles = () => within(screen.getByRole("menubar"));
describe("main menu presentation", () => {
it("keeps the hamburger default, expanding on click and collapsing on Escape", async () => {
const expanded = vi.fn();
render(<MainMenu onExpandedChange={expanded} />);
expect(screen.queryByRole("menubar")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Main Menu" }));
expect(await screen.findByRole("menu", { name: "File" })).toBeInTheDocument();
expect(expanded).toHaveBeenLastCalledWith(true);
fireEvent.keyDown(document.activeElement!, { key: "ArrowRight" });
expect(await screen.findByRole("menu", { name: "Edit" })).toBeInTheDocument();
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
expect(screen.queryByRole("menubar")).toBeNull();
expect(screen.getByRole("button", { name: "Main Menu" })).toBeInTheDocument();
expect(expanded).toHaveBeenLastCalledWith(false);
alt();
expect(await screen.findByRole("menu", { name: "File" })).toBeInTheDocument();
});
it("shows merged titles, switches menus with arrows, and retains titles after dismissal or an action", async () => {
setStyle("merged");
const expanded = vi.fn();
render(
<>
<MainMenu onExpandedChange={expanded} />
<button>Toolbar control</button>
</>,
);
await screen.findByRole("menubar");
expect(screen.queryByRole("button", { name: "Main Menu" })).toBeNull();
fireEvent.click(titles().getByRole("menuitem", { name: "File" }));
expect(await screen.findByRole("menu", { name: "File" })).toBeInTheDocument();
fireEvent.keyDown(document.activeElement!, { key: "ArrowRight" });
expect(await screen.findByRole("menu", { name: "Edit" })).toBeInTheDocument();
fireEvent.keyDown(document.activeElement!, { key: "ArrowLeft" });
expect(await screen.findByRole("menu", { name: "File" })).toBeInTheDocument();
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
expect(screen.queryByRole("menu")).toBeNull();
expect(screen.getByRole("menubar")).toBeInTheDocument();
fireEvent.click(titles().getByRole("menuitem", { name: "Edit" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Undo" }));
expect(screen.queryByRole("menu")).toBeNull();
expect(screen.getByRole("menubar")).toBeInTheDocument();
fireEvent.click(titles().getByRole("menuitem", { name: "File" }));
fireEvent.pointerDown(screen.getByRole("button", { name: "Toolbar control" }));
await act(() => new Promise((resolve) => setTimeout(resolve)));
expect(screen.queryByRole("menu")).toBeNull();
expect(expanded).not.toHaveBeenCalledWith(true);
});
it.each(["hamburger", "merged"])(
"%s switches on title clicks without an outside dismissal, then closes for toolbar clicks",
async (style) => {
setStyle(style);
render(
<>
<MainMenu />
<button>Toolbar control</button>
</>,
);
if (style === "hamburger") fireEvent.click(screen.getByRole("button", { name: "Main Menu" }));
await screen.findByRole("menubar");
if (style === "merged") fireEvent.click(titles().getByRole("menuitem", { name: "File" }));
const edit = titles().getByRole("menuitem", { name: "Edit" });
fireEvent.pointerDown(edit);
fireEvent.click(edit);
await act(() => new Promise((resolve) => setTimeout(resolve)));
expect(screen.getByRole("menu", { name: "Edit" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "Toolbar control" }));
await act(() => new Promise((resolve) => setTimeout(resolve)));
expect(screen.queryByRole("menu")).toBeNull();
},
);
it("focuses the merged titles with lone Alt and navigates before opening", async () => {
setStyle("merged");
render(
<>
<button>Editor</button>
<MainMenu />
</>,
);
await screen.findByRole("menubar");
const editor = screen.getByRole("button", { name: "Editor" });
editor.focus();
alt();
expect(titles().getByRole("menuitem", { name: "File" })).toHaveFocus();
expect(screen.queryByRole("menu")).toBeNull();
fireEvent.keyDown(document.activeElement!, { key: "ArrowLeft" });
expect(titles().getByRole("menuitem", { name: "Edit" })).toHaveFocus();
fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
expect(await screen.findByRole("menu", { name: "Edit" })).toBeInTheDocument();
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
expect(titles().getByRole("menuitem", { name: "Edit" })).toHaveFocus();
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
expect(editor).toHaveFocus();
});
it("falls back to a hamburger popup when narrow and restores titles when space returns", async () => {
available = 100;
setStyle("merged");
const expanded = vi.fn();
render(<MainMenu onExpandedChange={expanded} />);
await act(async () => {});
expect(screen.queryByRole("menubar")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Main Menu" }));
const root = await screen.findByRole("menu", { name: "Main Menu" });
fireEvent.click(within(root).getByRole("menuitem", { name: /File/ }));
expect(await screen.findByRole("menuitem", { name: "Open" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitem", { name: "Open" }));
expect(screen.getByRole("button", { name: "Main Menu" })).toBeInTheDocument();
expect(expanded).not.toHaveBeenCalledWith(true);
act(() => {
available = 500;
resize();
});
expect(screen.getByRole("menubar")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Main Menu" })).toBeNull();
act(() => {
available = 100;
resize();
});
expect(screen.queryByRole("menubar")).toBeNull();
expect(screen.getByRole("button", { name: "Main Menu" })).toBeInTheDocument();
});
it("applies the setting immediately and restores toolbar visibility when switching from an expanded hamburger", async () => {
const expanded = vi.fn();
render(<MainMenu onExpandedChange={expanded} />);
fireEvent.click(screen.getByRole("button", { name: "Main Menu" }));
await screen.findByRole("menu");
expect(expanded).toHaveBeenLastCalledWith(true);
act(() => setStyle("merged"));
await screen.findByRole("menubar");
expect(screen.queryByRole("menu")).toBeNull();
expect(expanded).toHaveBeenLastCalledWith(false);
act(() => setStyle("hamburger"));
expect(screen.queryByRole("menubar")).toBeNull();
expect(screen.getByRole("button", { name: "Main Menu" })).toBeInTheDocument();
});
});
+163 -57
View File
@@ -1,13 +1,8 @@
/**
* Frameless title bar (Windows, Linux): 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`). As in
* Windows apps and IDEA, pressing and releasing Alt alone opens it too.
*/
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
/** Main menu for the frameless title bar, with hamburger and persistent title modes. */
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import { lookup, t } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { lookup, t, useI18n } from "@/platform/i18n";
import { commands, type MenuNode } from "@/platform/ipc";
import { usePlatform } from "@/platform/platformInfo";
import { Icon, MenuList, Popup, type MenuItem } from "@/ui";
@@ -70,77 +65,163 @@ export function onLoneAlt(target: Window, onAlt: () => void): () => void {
};
}
export function MainMenu({ onExpandedChange }: { onExpandedChange?: (expanded: boolean) => void }) {
const [menus, setMenus] = useState<Submenu[] | null>(null);
const [open, setOpen] = useState<number | null>(null);
const [bar, setBar] = useState<HTMLDivElement | null>(null);
// A press on another title closes the open menu first (outside click); keep the bar expanded.
const pressedTitle = useRef(false);
async function loadMenus(): Promise<Submenu[]> {
const model: MenuNode[] = await commands.menuModel().catch(() => []);
return model.filter(isSubmenu);
}
type MainMenuProps = { onExpandedChange?: (expanded: boolean) => void };
export function MainMenu(props: MainMenuProps) {
const merged = useProject((s) => s.settings?.appearance?.mainMenuStyle === "merged");
return <MainMenuBar key={String(merged)} merged={merged} {...props} />;
}
function MainMenuBar({ merged, onExpandedChange }: MainMenuProps & { merged: boolean }) {
useI18n((s) => s.locale);
const [menus, setMenus] = useState<Submenu[]>([]);
const [expanded, setExpanded] = useState(false);
const [open, setOpen] = useState<number | null>(null);
const [focused, setFocused] = useState(0);
const [bar, setBar] = useState<HTMLDivElement | null>(null);
const slot = useRef<HTMLDivElement>(null);
const measure = useRef<HTMLDivElement>(null);
const hamburger = useRef<HTMLButtonElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
const [fits, setFits] = useState(false);
const pressedTitle = useRef(false);
const persistent = merged && fits;
const showTitles = persistent || (!merged && expanded);
useEffect(() => {
let disposed = false;
if (merged)
void loadMenus().then((top) => {
if (!disposed) setMenus(top);
});
return () => {
disposed = true;
};
}, [merged]);
useEffect(() => {
if (!merged || open === null) return;
let disposed = false;
void loadMenus().then((top) => {
if (!disposed && top.length) setMenus(top);
});
return () => {
disposed = true;
};
}, [merged, open]);
useEffect(() => {
onExpandedChange?.(!merged && expanded);
return () => onExpandedChange?.(false);
}, [merged, expanded, onExpandedChange]);
// The invisible copy keeps the natural flex basis even while the slot shrinks.
// Measuring the allocated slot includes all neighbouring toolbar/caption controls.
useLayoutEffect(() => {
if (!merged || !slot.current || !measure.current) return;
const update = () => setFits(menus.length > 0 && slot.current!.clientWidth >= measure.current!.scrollWidth);
const observer = new ResizeObserver(update);
observer.observe(slot.current);
observer.observe(measure.current);
update();
return () => observer.disconnect();
}, [merged, menus]);
const close = () => {
setOpen(null);
setExpanded(false);
};
const expand = async () => {
const model: MenuNode[] = await commands.menuModel().catch(() => []);
const top = model.filter(isSubmenu);
const top = await loadMenus();
if (!top.length) return;
setMenus(top);
setExpanded(true);
setOpen(0);
onExpandedChange?.(true);
};
const collapse = () => {
setOpen(null);
setMenus(null);
onExpandedChange?.(false);
const focusTitle = (i: number) => {
setFocused(i);
(bar?.children[i] as HTMLElement | undefined)?.focus();
};
const onMenuClose = (reason?: string) => {
if (reason !== "outside") return collapse();
if (reason !== "outside") {
if (reason === "escape" && persistent) focusTitle(open ?? focused);
return close();
}
setTimeout(() => {
if (pressedTitle.current) pressedTitle.current = false;
else collapse();
else close();
});
};
useEffect(() => onLoneAlt(window, () => (menus ? collapse() : void expand())));
const step = (delta: number) => {
if (!menus || open === null) return;
setOpen((open + delta + menus.length) % menus.length);
};
// ←/→ not used by the menu (top level, or a leaf item) move to the neighbouring menu.
useEffect(() =>
onLoneAlt(window, () => {
if (open !== null || expanded) {
close();
if (persistent) focusTitle(focused);
} else if (persistent) {
previousFocus.current = document.activeElement as HTMLElement;
focusTitle(0);
} else void expand();
}),
);
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
step(e.key === "ArrowRight" ? 1 : -1);
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
if (!menus.length) return;
e.preventDefault();
const next = ((open ?? focused) + (e.key === "ArrowRight" ? 1 : -1) + menus.length) % menus.length;
if (open !== null) setOpen(next);
else focusTitle(next);
} else if (e.key === "ArrowDown" && open === null) {
e.preventDefault();
setOpen(focused);
} else if (e.key === "Escape" && open === null) {
close();
previousFocus.current?.focus();
}
};
if (!menus)
return (
<button
type="button"
className={styles.mainMenuButton}
aria-label={t("menu.mainMenu")}
title={t("menu.mainMenu")}
onClick={() => void expand()}
>
<Icon name="main-menu" size={16} />
</button>
);
return (
<div ref={setBar} role="menubar" className={styles.menuBar}>
const button = (
<button
ref={hamburger}
type="button"
className={styles.mainMenuButton}
aria-label={t("menu.mainMenu")}
title={t("menu.mainMenu")}
aria-haspopup="menu"
aria-expanded={expanded}
onClick={() => {
if (expanded) close();
else void expand();
}}
>
<Icon name="main-menu" size={16} />
</button>
);
const titles = (
<div ref={setBar} role="menubar" className={styles.menuBar} onKeyDown={onKeyDown}>
{menus.map((m, i) => (
<button
key={m.id}
type="button"
role="menuitem"
tabIndex={focused === i ? 0 : -1}
aria-haspopup="menu"
aria-expanded={open === i}
className={clsx(styles.menuTitle, open === i && styles.menuTitleOpen)}
onFocus={() => setFocused(i)}
onPointerDown={() => {
pressedTitle.current = true;
setTimeout(() => {
pressedTitle.current = false;
});
}}
onPointerEnter={() => {
if (open !== null) setOpen(i);
}}
onClick={() => {
pressedTitle.current = false;
if (open === i) collapse();
if (open === i) close();
else setOpen(i);
}}
>
@@ -156,16 +237,41 @@ export function MainMenu({ onExpandedChange }: { onExpandedChange?: (expanded: b
offset={2}
role="presentation"
autoFocus={false}
restoreFocus={!persistent}
onClose={onMenuClose}
className={menuStyles.popup}
>
<div onKeyDown={onKeyDown}>
<MenuList
items={menuItems(menus[open].children)}
onDone={collapse}
aria-label={label(menus[open].id, menus[open].label)}
/>
</div>
<MenuList
items={menuItems(menus[open].children)}
onDone={close}
aria-label={label(menus[open].id, menus[open].label)}
/>
</Popup>
)}
</div>
);
if (!merged) return showTitles ? titles : button;
return (
<div ref={slot} className={styles.mainMenuSlot}>
<div ref={measure} className={clsx(styles.menuBar, styles.menuMeasure)} aria-hidden="true" inert>
{menus.map((m) => (
<span key={m.id} className={styles.menuTitle}>
{label(m.id, m.label)}
</span>
))}
</div>
<div className={styles.menuPresentation}>{showTitles ? titles : button}</div>
{!showTitles && expanded && (
<Popup
open
anchor={hamburger}
onClose={close}
placement="bottom-start"
role="presentation"
autoFocus={false}
className={menuStyles.popup}
>
<MenuList items={menuItems(menus)} onDone={close} aria-label={t("menu.mainMenu")} />
</Popup>
)}
</div>
+26 -1
View File
@@ -125,7 +125,32 @@
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
}
/* The expanded main menu: File Edit View … in place of the project widgets. */
/* Preserve the menu's natural width while allowing its slot to yield to toolbar controls. */
.mainMenuSlot {
position: relative;
flex: 0 1 auto;
min-width: 34px;
height: 30px;
overflow: hidden;
}
.mainMenuSlot ~ .item,
.mainMenuSlot ~ .tools {
flex-shrink: 0;
}
.menuMeasure {
width: max-content;
visibility: hidden;
pointer-events: none;
}
.menuPresentation {
position: absolute;
top: 0;
left: 0;
height: 100%;
display: flex;
align-items: center;
}
/* The main menu titles. */
.menuBar {
flex: none;
display: flex;
+32 -1
View File
@@ -1,8 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("./WindowControls", () => ({ WindowControls: () => null }));
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { registerActions } from "@/platform/actions";
import { commands, type Settings } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { usePlatform } from "@/platform/platformInfo";
import { useThemeOverride } from "./theme";
import { customizeToolbarItems, gearMenuItems, TitleBar } from "./TitleBar";
@@ -103,3 +105,32 @@ describe("title bar in Distraction Free Mode (bare)", () => {
}
});
});
describe("merged title bar menu", () => {
it.each(["windows", "linux", "macos"])(
"%s keeps toolbar controls available and uses the menu only when frameless",
async (os) => {
const savedPlatform = usePlatform.getState();
const savedProject = useProject.getState();
const model = vi
.spyOn(commands, "menuModel")
.mockResolvedValue([{ kind: "submenu", id: "file", label: "File", children: [] }]);
usePlatform.setState({ os, framelessTitleBar: os !== "macos", overlayTitleBar: os === "macos" });
useProject.setState({ settings: { appearance: { mainMenuStyle: "merged" } } as Settings });
try {
render(<TitleBar />);
await act(async () => {});
expect(!!screen.queryByRole("menubar")).toBe(os !== "macos");
expect(screen.queryByRole("button", { name: "Main Menu" })).toBeNull();
if (os !== "macos") fireEvent.click(screen.getByRole("menuitem", { name: "File" }));
expect(screen.getByRole("button", { name: "OpenIDEA" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "IDE and Project Settings" }));
expect(await screen.findByRole("menu", { name: "IDE and Project Settings" })).toBeInTheDocument();
} finally {
usePlatform.setState(savedPlatform);
useProject.setState(savedProject);
model.mockRestore();
}
},
);
});