diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index e2b347abf..5900f7e80 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -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, @@ -716,6 +719,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(); diff --git a/src/bindings.ts b/src/bindings.ts index 9af2daa45..8b04d1432 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1408,6 +1408,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, diff --git a/src/features/settings/__tests__/mainMenuStyle.test.tsx b/src/features/settings/__tests__/mainMenuStyle.test.tsx new file mode 100644 index 000000000..87f6cae25 --- /dev/null +++ b/src/features/settings/__tests__/mainMenuStyle.test.tsx @@ -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(); + 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(); + 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"); + }); +}); diff --git a/src/features/settings/model/behaviorSchema.ts b/src/features/settings/model/behaviorSchema.ts index 73adc3ccd..777110ee2 100644 --- a/src/features/settings/model/behaviorSchema.ts +++ b/src/features/settings/model/behaviorSchema.ts @@ -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"), diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index ecd39bfbc..c8e352062 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -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 ( + +