import type { ContextMenuItem } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Electron from "electron"; export interface ElectronMenuPosition { readonly x: number; readonly y: number; } export interface ElectronMenuContextInput { readonly window: Electron.BrowserWindow; readonly items: readonly ContextMenuItem[]; readonly position: Option.Option; } export interface ElectronMenuTemplateInput { readonly window: Electron.BrowserWindow; readonly template: readonly Electron.MenuItemConstructorOptions[]; } const ElectronMenuOperation = Schema.Literals([ "set-application-menu", "popup-template", "show-context-menu", ]); export class ElectronMenuOperationError extends Schema.TaggedErrorClass()( "ElectronMenuOperationError", { operation: ElectronMenuOperation, platform: Schema.String, windowId: Schema.NullOr(Schema.Number), itemCount: Schema.Number, cause: Schema.Defect(), }, ) { override get message(): string { const window = this.windowId === null ? "" : ` for window ${this.windowId}`; return `Electron menu operation ${JSON.stringify(this.operation)} failed${window} with ${this.itemCount} items on ${this.platform}.`; } } export class ElectronMenu extends Context.Service< ElectronMenu, { readonly setApplicationMenu: ( template: readonly Electron.MenuItemConstructorOptions[], ) => Effect.Effect; readonly showContextMenu: ( input: ElectronMenuContextInput, ) => Effect.Effect>; readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronMenu") {} function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { const normalizedItems: ContextMenuItem[] = []; for (const sourceItem of source) { if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { continue; } // Header items are decorative section labels for the web fallback only — // Electron's native menu has no equivalent affordance, so we skip them. if (sourceItem.header === true) { continue; } const normalizedItem: ContextMenuItem = { id: sourceItem.id, label: sourceItem.label, destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), }; if (sourceItem.children) { const normalizedChildren = normalizeContextMenuItems(sourceItem.children); if (normalizedChildren.length === 0) { continue; } normalizedItem.children = normalizedChildren; } normalizedItems.push(normalizedItem); } return normalizedItems; } // Renderer positions arrive in CSS pixels; popup() expects window points, so // page zoom must be factored in or menus drift proportionally to their // distance from the window origin. const normalizePosition = ( position: Option.Option, zoomFactor: number, ): Option.Option => Option.filter( position, ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor), ).pipe( Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })), ); export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; let destructiveMenuIconCache: Option.Option | undefined; const getDestructiveMenuIcon = (): Option.Option => { if (platform !== "darwin") { return Option.none(); } if (destructiveMenuIconCache !== undefined) { return destructiveMenuIconCache; } try { const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ width: 12, height: 12, }); icon.setTemplateImage(true); destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); } catch { destructiveMenuIconCache = Option.none(); } return destructiveMenuIconCache; }; const buildTemplate = ( entries: readonly ContextMenuItem[], complete: (selectedItemId: Option.Option) => void, ): Electron.MenuItemConstructorOptions[] => { const template: Electron.MenuItemConstructorOptions[] = []; let hasInsertedDestructiveSeparator = false; let sectionStartedByExplicitSeparator = false; const appendSeparator = () => { if (template.length === 0 || template.at(-1)?.type === "separator") return; template.push({ type: "separator" }); }; for (const item of entries) { if (item.separatorBefore) { appendSeparator(); sectionStartedByExplicitSeparator = true; } if ( item.destructive && !hasInsertedDestructiveSeparator && !sectionStartedByExplicitSeparator && template.length > 0 ) { appendSeparator(); hasInsertedDestructiveSeparator = true; } const itemOption: Electron.MenuItemConstructorOptions = { label: item.label, enabled: !item.disabled, }; if (item.children && item.children.length > 0) { itemOption.submenu = buildTemplate(item.children, complete); } else { itemOption.click = () => complete(Option.some(item.id)); } if (item.destructive && (!item.children || item.children.length === 0)) { const destructiveIcon = getDestructiveMenuIcon(); if (Option.isSome(destructiveIcon)) { itemOption.icon = destructiveIcon.value; } } template.push(itemOption); } return template; }; return ElectronMenu.of({ setApplicationMenu: (template) => Effect.try({ try: () => { Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); }, catch: (cause) => new ElectronMenuOperationError({ operation: "set-application-menu", platform, windowId: null, itemCount: template.length, cause, }), }).pipe(Effect.orDie), popupTemplate: (input) => input.template.length === 0 ? Effect.void : Effect.try({ try: () => Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window, }), catch: (cause) => new ElectronMenuOperationError({ operation: "popup-template", platform, windowId: input.window.id, itemCount: input.template.length, cause, }), }).pipe(Effect.orDie), showContextMenu: (input) => Effect.callback>((resume) => { const normalizedItems = normalizeContextMenuItems(input.items); if (normalizedItems.length === 0) { resume(Effect.succeed(Option.none())); return; } let completed = false; const complete = (selectedItemId: Option.Option) => { if (completed) { return; } completed = true; resume(Effect.succeed(selectedItemId)); }; try { const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); const popupPosition = normalizePosition( input.position, input.window.webContents.getZoomFactor(), ); const popupOptions = Option.match(popupPosition, { onNone: (): Electron.PopupOptions => ({ window: input.window, callback: () => complete(Option.none()), }), onSome: (position): Electron.PopupOptions => ({ window: input.window, x: position.x, y: position.y, callback: () => complete(Option.none()), }), }); menu.popup(popupOptions); } catch (cause) { if (completed) { return; } completed = true; resume( Effect.die( new ElectronMenuOperationError({ operation: "show-context-menu", platform, windowId: input.window.id, itemCount: normalizedItems.length, cause, }), ), ); } }), }); }); export const layer = Layer.effect(ElectronMenu, make);