import type { ContextMenuItem } from "@t3tools/contracts";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";import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; export interface ElectronMenuPosition { readonly x: number; readonly y: number;} export interface ElectronMenuContextInput { readonly window: Electron.BrowserWindow; readonly items: readonly ContextMenuItem[]; readonly position: Option.Option<ElectronMenuPosition>;} export interface ElectronMenuTemplateInput { readonly window: Electron.BrowserWindow; readonly template: readonly Electron.MenuItemConstructorOptions[];} export interface ElectronMenuShape { readonly setApplicationMenu: ( template: readonly Electron.MenuItemConstructorOptions[], ) => Effect.Effect<void>; readonly showContextMenu: ( input: ElectronMenuContextInput, ) => Effect.Effect<Option.Option<string>>; readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect<void>;} export class ElectronMenu extends Context.Service<ElectronMenu, ElectronMenuShape>()( "@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<ElectronMenuPosition>,): Option.Option<ElectronMenuPosition> => 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 layer = Layer.effect( ElectronMenu, Effect.gen(function* () { const platform = yield* HostProcessPlatform; let destructiveMenuIconCache: Option.Option<Electron.NativeImage> | undefined; const getDestructiveMenuIcon = (): Option.Option<Electron.NativeImage> => { 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<string>) => 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<Option.Option<string>>((resume) => { const normalizedItems = normalizeContextMenuItems(input.items); if (normalizedItems.length === 0) { resume(Effect.succeed(Option.none())); return; } let completed = false; const complete = (selectedItemId: Option.Option<string>) => { 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); }), }); }),);
import type { ContextMenuItem } from "@t3tools/contracts";
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";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
export interface ElectronMenuPosition {
readonly x: number;
readonly y: number;
}
export interface ElectronMenuContextInput {
readonly window: Electron.BrowserWindow;
readonly items: readonly ContextMenuItem[];
readonly position: Option.Option<ElectronMenuPosition>;
}
export interface ElectronMenuTemplateInput {
readonly window: Electron.BrowserWindow;
readonly template: readonly Electron.MenuItemConstructorOptions[];
}
export interface ElectronMenuShape {
readonly setApplicationMenu: (
template: readonly Electron.MenuItemConstructorOptions[],
) => Effect.Effect<void>;
readonly showContextMenu: (
input: ElectronMenuContextInput,
) => Effect.Effect<Option.Option<string>>;
readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect<void>;
}
export class ElectronMenu extends Context.Service<ElectronMenu, ElectronMenuShape>()(
"@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<ElectronMenuPosition>,
): Option.Option<ElectronMenuPosition> =>
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 layer = Layer.effect(
ElectronMenu,
Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
let destructiveMenuIconCache: Option.Option<Electron.NativeImage> | undefined;
const getDestructiveMenuIcon = (): Option.Option<Electron.NativeImage> => {
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<string>) => 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<Option.Option<string>>((resume) => {
const normalizedItems = normalizeContextMenuItems(input.items);
if (normalizedItems.length === 0) {
resume(Effect.succeed(Option.none()));
return;
}
let completed = false;
const complete = (selectedItemId: Option.Option<string>) => {
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);
}),
});
}),
);