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 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[]; } 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, }; if (sourceItem.children) { const normalizedChildren = normalizeContextMenuItems(sourceItem.children); if (normalizedChildren.length === 0) { continue; } normalizedItem.children = normalizedChildren; } normalizedItems.push(normalizedItem); } return normalizedItems; } const normalizePosition = ( position: Option.Option, ): Option.Option => Option.filter( position, ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); 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; for (const item of entries) { if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { template.push({ type: "separator" }); 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.sync(() => { Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); }), popupTemplate: (input) => Effect.sync(() => { if (input.template.length === 0) { return; } Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); }), 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)); }; const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); const popupPosition = normalizePosition(input.position); 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); }), }); }); export const layer = Layer.effect(ElectronMenu, make);