diff --git a/src/main/initializers/ipc.ts b/src/main/initializers/ipc.ts index 206a1673db..508848f2c1 100644 --- a/src/main/initializers/ipc.ts +++ b/src/main/initializers/ipc.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { BrowserWindow, dialog, IpcMainInvokeEvent } from "electron"; +import { BrowserWindow, dialog, IpcMainInvokeEvent, Menu } from "electron"; import { clusterFrameMap } from "../../common/cluster-frames"; import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler, clusterSetDeletingHandler, clusterClearDeletingHandler } from "../../common/cluster-ipc"; import type { ClusterId } from "../../common/cluster-types"; @@ -30,10 +30,11 @@ import { catalogEntityRegistry } from "../catalog"; import { pushCatalogToRenderer } from "../catalog-pusher"; import { ClusterManager } from "../cluster-manager"; import { ResourceApplier } from "../resource-applier"; -import { WindowManager } from "../window-manager"; +import { IpcMainWindowEvents, WindowManager } from "../window-manager"; import path from "path"; import { remove } from "fs-extra"; import { AppPaths } from "../../common/app-paths"; +import { getAppMenu } from "../menu"; export function initIpcMainHandlers() { ipcMainHandle(clusterActivateHandler, (event, clusterId: ClusterId, force = false) => { @@ -148,4 +149,16 @@ export function initIpcMainHandlers() { return dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), dialogOpts); }); + + ipcMainOn(IpcMainWindowEvents.OPEN_CONTEXT_MENU, async (event) => { + const menu = Menu.buildFromTemplate(getAppMenu(WindowManager.getInstance())); + const options = { + ...BrowserWindow.fromWebContents(event.sender), + // Center of the topbar menu icon + x: 20, + y: 20, + } as Electron.PopupOptions; + + menu.popup(options); + }); } diff --git a/src/main/menu.ts b/src/main/menu.ts index 08012cee23..82ae30bf30 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -61,7 +61,7 @@ export function showAbout(browserWindow: BrowserWindow) { }); } -export function buildMenu(windowManager: WindowManager) { +export function getAppMenu(windowManager: WindowManager) { function ignoreIf(check: boolean, menuItems: MenuItemConstructorOptions[]) { return check ? [] : menuItems; } @@ -316,5 +316,10 @@ export function buildMenu(windowManager: WindowManager) { appMenu.delete("mac"); } - Menu.setApplicationMenu(Menu.buildFromTemplate([...appMenu.values()])); + return [...appMenu.values()]; + +} + +export function buildMenu(windowManager: WindowManager) { + Menu.setApplicationMenu(Menu.buildFromTemplate(getAppMenu(windowManager))); } diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index 70713b22e8..155265fc4b 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -29,9 +29,13 @@ import { delay, iter, Singleton } from "../common/utils"; import { ClusterFrameInfo, clusterFrameMap } from "../common/cluster-frames"; import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; import logger from "./logger"; -import { productName } from "../common/vars"; +import { isMac, productName } from "../common/vars"; import { LensProxy } from "./lens-proxy"; +export const enum IpcMainWindowEvents { + OPEN_CONTEXT_MENU = "window:open-context-menu", +} + function isHideable(window: BrowserWindow | null): boolean { return Boolean(window && !window.isDestroyed()); } @@ -81,7 +85,8 @@ export class WindowManager extends Singleton { show: false, minWidth: 700, // accommodate 800 x 600 display minimum minHeight: 500, // accommodate 800 x 600 display minimum - titleBarStyle: "hiddenInset", + titleBarStyle: isMac ? "hiddenInset" : "hidden", + frame: isMac, backgroundColor: "#1e2124", webPreferences: { nodeIntegration: true, diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index f42a671de6..1a8d10995b 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -155,8 +155,6 @@ export async function bootstrap(comp: () => Promise, di: Dependenc render( - {isMac &&
} - {DefaultProps(App)} , diff --git a/src/renderer/components/app.scss b/src/renderer/components/app.scss index 79903a8ca9..feba4d8666 100755 --- a/src/renderer/components/app.scss +++ b/src/renderer/components/app.scss @@ -97,17 +97,6 @@ html, body { } } -#draggable-top { - @include set-draggable; - position: absolute; - left: 0; - top: 0; - width: 100%; - height: var(--main-layout-header); - z-index: 1000; - pointer-events: none; -} - body { font: $font-size $font-main; } diff --git a/src/renderer/components/layout/__tests__/topbar-win-linux.test.tsx b/src/renderer/components/layout/__tests__/topbar-win-linux.test.tsx new file mode 100644 index 0000000000..1307e181cd --- /dev/null +++ b/src/renderer/components/layout/__tests__/topbar-win-linux.test.tsx @@ -0,0 +1,115 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import React from "react"; +import { render, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom/extend-expect"; +import { TopBar } from "../topbar"; +import { TopBarRegistry } from "../../../../extensions/registries"; +import { IpcMainWindowEvents } from "../../../../main/window-manager"; +import { broadcastMessage } from "../../../../common/ipc"; +import * as vars from "../../../../common/vars"; + +const mockConfig = vars as { isWindows: boolean, isLinux: boolean }; + +jest.mock("../../../../common/ipc"); + +jest.mock("../../../../common/vars", () => { + return { + __esModule: true, + isWindows: null, + isLinux: null, + }; +}); + +const mockMinimize = jest.fn(); +const mockMaximize = jest.fn(); +const mockUnmaximize = jest.fn(); +const mockClose = jest.fn(); + +jest.mock("@electron/remote", () => { + return { + getCurrentWindow: () => ({ + minimize: () => mockMinimize(), + maximize: () => mockMaximize(), + unmaximize: () => mockUnmaximize(), + close: () => mockClose(), + isMaximized: () => false, + }), + }; +}); + +describe(" in Windows and Linux", () => { + beforeEach(() => { + TopBarRegistry.createInstance(); + }); + + afterEach(() => { + TopBarRegistry.resetInstance(); + }); + + it("shows window controls on Windows", () => { + mockConfig.isWindows = true; + mockConfig.isLinux = false; + + const { getByTestId } = render(); + + expect(getByTestId("window-menu")).toBeInTheDocument(); + expect(getByTestId("window-minimize")).toBeInTheDocument(); + expect(getByTestId("window-maximize")).toBeInTheDocument(); + expect(getByTestId("window-close")).toBeInTheDocument(); + }); + + it("shows window controls on Linux", () => { + mockConfig.isWindows = false; + mockConfig.isLinux = true; + + const { getByTestId } = render(); + + expect(getByTestId("window-menu")).toBeInTheDocument(); + expect(getByTestId("window-minimize")).toBeInTheDocument(); + expect(getByTestId("window-maximize")).toBeInTheDocument(); + expect(getByTestId("window-close")).toBeInTheDocument(); + }); + + it("triggers ipc events on click", () => { + mockConfig.isWindows = true; + + const { getByTestId } = render(); + + const menu = getByTestId("window-menu"); + const minimize = getByTestId("window-minimize"); + const maximize = getByTestId("window-maximize"); + const close = getByTestId("window-close"); + + fireEvent.click(menu); + expect(broadcastMessage).toHaveBeenCalledWith(IpcMainWindowEvents.OPEN_CONTEXT_MENU); + + fireEvent.click(minimize); + expect(mockMinimize).toHaveBeenCalled(); + + fireEvent.click(maximize); + expect(mockMaximize).toHaveBeenCalled(); + + fireEvent.click(close); + expect(mockClose).toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/components/layout/__tests__/topbar.test.tsx b/src/renderer/components/layout/__tests__/topbar.test.tsx index 7eb4b01dda..110ddef800 100644 --- a/src/renderer/components/layout/__tests__/topbar.test.tsx +++ b/src/renderer/components/layout/__tests__/topbar.test.tsx @@ -25,6 +25,12 @@ import "@testing-library/jest-dom/extend-expect"; import { TopBar } from "../topbar"; import { TopBarRegistry } from "../../../../extensions/registries"; +jest.mock("../../../../common/vars", () => { + return { + isMac: true, + }; +}); + jest.mock( "electron", () => ({ @@ -65,6 +71,7 @@ jest.mock("@electron/remote", () => { }]; }, }, + getCurrentWindow: () => jest.fn(), }; }); @@ -134,4 +141,13 @@ describe("", () => { expect(await getByTestId(testId)).toHaveTextContent(text); }); + + it("doesn't show windows title buttons", () => { + const { queryByTestId } = render(); + + expect(queryByTestId("window-menu")).not.toBeInTheDocument(); + expect(queryByTestId("window-minimize")).not.toBeInTheDocument(); + expect(queryByTestId("window-maximize")).not.toBeInTheDocument(); + expect(queryByTestId("window-close")).not.toBeInTheDocument(); + }); }); diff --git a/src/renderer/components/layout/topbar.module.css b/src/renderer/components/layout/topbar.module.css index 72a88345f1..2904c09d20 100644 --- a/src/renderer/components/layout/topbar.module.css +++ b/src/renderer/components/layout/topbar.module.css @@ -20,22 +20,44 @@ */ .topBar { - display: grid; - grid-template-columns: [title] 1fr [controls] auto; - grid-template-rows: var(--main-layout-header); - grid-template-areas: "title controls"; + display: flex; background-color: var(--layoutBackground); z-index: 2; width: 100%; grid-area: topbar; + height: var(--main-layout-header); + justify-content: space-between; + + /* Use topbar as draggable region */ + -webkit-user-select: none; + -webkit-app-region: drag; } :global(.is-mac) .topBar { padding-left: var(--hotbar-width); } +.winMenu { + width: var(--hotbar-width); + + > div { + @apply flex items-center justify-center; + width: 40px; + height: 40px; + + &:hover { + background-color: var(--borderFaintColor); + } + + &:active { + background-color: var(--borderColor); + } + } +} + .tools { @apply flex items-center; + -webkit-app-region: no-drag; } .controls { @@ -44,4 +66,63 @@ align-items: center; display: flex; height: 100%; + -webkit-app-region: no-drag; } + +.windowButtons { + display: flex; + margin-left: 1.5rem; + margin-right: -1.5rem; + + > div { + @apply flex items-center justify-center; + width: 40px; + height: 40px; + + svg { + width: 12px; + height: 12px; + } + } + + &.linuxButtons { + > div { + width: 16px; + height: 16px; + border-radius: 50%; + margin-right: 1.1rem; + color: var(--textColorAccent); + + svg { + width: 8px; + height: 8px; + } + } + + .close { + color: white; + background-color: #e63e02; /* Standard close button bg color on ubuntu */ + } + + .close:hover { + background-color: #ff5a23; + } + } +} + +.minimize, .maximize { + &:hover { + background-color: var(--borderFaintColor); + } + + &:active { + background-color: var(--borderColor); + } +} + +.close { + &:hover { + color: white; + background-color: #ef4b4e; + } +} \ No newline at end of file diff --git a/src/renderer/components/layout/topbar.tsx b/src/renderer/components/layout/topbar.tsx index dfaab4d3c2..af47fd2548 100644 --- a/src/renderer/components/layout/topbar.tsx +++ b/src/renderer/components/layout/topbar.tsx @@ -20,16 +20,19 @@ */ import styles from "./topbar.module.css"; -import React, { useEffect } from "react"; +import React, { useEffect, useMemo, useRef } from "react"; import { observer } from "mobx-react"; import { TopBarRegistry } from "../../../extensions/registries"; import { Icon } from "../icon"; import { webContents, getCurrentWindow } from "@electron/remote"; import { observable } from "mobx"; -import { ipcRendererOn } from "../../../common/ipc"; +import { broadcastMessage, ipcRendererOn } from "../../../common/ipc"; import { watchHistoryState } from "../../remote-helpers/history-updater"; import { isActiveRoute, navigate } from "../../navigation"; import { catalogRoute, catalogURL } from "../../../common/routes"; +import { IpcMainWindowEvents } from "../../../main/window-manager"; +import { isLinux, isWindows } from "../../../common/vars"; +import { cssNames } from "../../utils"; interface Props extends React.HTMLAttributes { } @@ -46,6 +49,9 @@ ipcRendererOn("history:can-go-forward", (event, state: boolean) => { }); export const TopBar = observer(({ children, ...rest }: Props) => { + const elem = useRef(); + const window = useMemo(() => getCurrentWindow(), []); + const renderRegisteredItems = () => { const items = TopBarRegistry.getInstance().getItems(); @@ -70,6 +76,10 @@ export const TopBar = observer(({ children, ...rest }: Props) => { ); }; + const openContextMenu = () => { + broadcastMessage(IpcMainWindowEvents.OPEN_CONTEXT_MENU); + }; + const goHome = () => { navigate(catalogURL()); }; @@ -82,9 +92,20 @@ export const TopBar = observer(({ children, ...rest }: Props) => { webContents.getAllWebContents().find((webContent) => webContent.getType() === "window")?.goForward(); }; - const windowSizeToggle = () => { - const window = getCurrentWindow(); + const windowSizeToggle = (evt: React.MouseEvent) => { + if (elem.current != evt.target) { + // Skip clicking on child elements + return; + } + toggleMaximize(); + }; + + const minimizeWindow = () => { + window.minimize(); + }; + + const toggleMaximize = () => { if (window.isMaximized()) { window.unmaximize(); } else { @@ -92,6 +113,10 @@ export const TopBar = observer(({ children, ...rest }: Props) => { } }; + const closeWindow = () => { + window.close(); + }; + useEffect(() => { const disposer = watchHistoryState(); @@ -99,8 +124,15 @@ export const TopBar = observer(({ children, ...rest }: Props) => { }, []); return ( -
-
+
+
+ {(isWindows || isLinux) && ( +
+
+ +
+
+ )} {
{renderRegisteredItems()} {children} + {(isWindows || isLinux) && ( +
+
+
+
+ +
+
+ +
+
+ )}
);