main retired electrobun's native context menu (a documented no-op on Linux) in favour of an HTML one on every platform. Merging that here needs the menu to look right under the Win7 skin as well. Conflicts, all resolved by keeping both sides: - src/bun/index.ts — createAppRPC keeps the branch's mainWindow getter, drops main's removed contextMenu dep. - ui/src/app/app.ts — imports both TitleBar and ContextMenu. - ui/src/app/platform.ts — kept the branch's version (its IS_MAC_CHROME accounts for the frameless win7 chrome, and MOD_LABEL correctly keys on the real platform rather than the chrome), plus exporting IS_MAC and adding IS_LINUX for the menu's per-OS shell wording. Aero menu (branch-only): 7.css has no menu component, so it is hand-authored across both layers — the row <button>s shed their push-button slab in `skin` (revert-layer only reaches `utilities` from there), and the square-cornered slab, hairline border and gradient selection band are stated in `skinfix`. Built strictly from existing --w7-* vars, never literal hex, so the hand-written dark Win7 palette recolours it for free. Hover and the keyboard cursor share one band (the component marks the cursor with `bg-accent`, which is overridden alongside :hover) as a real Win7 menu does. Also answers a question the merge raised: frameless windows DO work on Linux. The GTK wrapper honours titleBarStyle "hidden" — it calls gtk_window_set_decorated(false) and logs "GTK: Created window without decorations (custom titlebar)". Verified on GNOME/Wayland and documented in architecture.md. Verified: backend `tsc --noEmit` clean, `bun run build:ui` clean (492.52 kB initial, still under the 500 kB budget), app launches with the skin active, frameless, no console errors and no GBM failures. Co-Authored-By: Claude <noreply@anthropic.com>
168 lines
5.2 KiB
TypeScript
168 lines
5.2 KiB
TypeScript
import {
|
|
Component,
|
|
ElementRef,
|
|
computed,
|
|
effect,
|
|
inject,
|
|
signal,
|
|
viewChild,
|
|
} from '@angular/core';
|
|
import { NgIcon } from '@ng-icons/core';
|
|
import { ContextMenuService, type MenuItem } from './context-menu.service';
|
|
import { FocusService } from './focus.service';
|
|
|
|
/** Rough per-item metrics, used only to decide whether the menu must flip. */
|
|
const ITEM_H = 26;
|
|
const SEPARATOR_H = 7;
|
|
const MENU_W = 224; // w-56
|
|
const EDGE_GAP = 6;
|
|
|
|
/**
|
|
* The panel right-click menu. Replaces electrobun's native ContextMenu, which
|
|
* is a no-op on Linux — see ContextMenuService for the why. Emits the same
|
|
* `ctx-*` / `col:*` action strings the native menu did.
|
|
*/
|
|
@Component({
|
|
selector: 'app-context-menu',
|
|
imports: [NgIcon],
|
|
template: `
|
|
@if (menu.request(); as req) {
|
|
<!-- click-outside catcher; mousedown so it beats the panel's own handlers -->
|
|
<div class="fixed inset-0 z-50" (mousedown)="close()" (contextmenu)="onBackdropContext($event)"></div>
|
|
<div
|
|
#menuEl
|
|
role="menu"
|
|
tabindex="-1"
|
|
(keydown)="onKeydown($event)"
|
|
[style.left.px]="position().x"
|
|
[style.top.px]="position().y"
|
|
class="w7-menu fixed z-50 w-56 rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl outline-none"
|
|
>
|
|
@for (item of req.items; track $index) {
|
|
@if (item.type === 'separator') {
|
|
<div class="w7-menu-sep my-1 border-t border-border"></div>
|
|
} @else {
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
[title]="item.tooltip ?? ''"
|
|
[class.bg-accent]="$index === activeIndex()"
|
|
(mouseenter)="activeIndex.set($index)"
|
|
(click)="pick(item)"
|
|
class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) hover:bg-accent"
|
|
>
|
|
<span class="w-3.5 shrink-0">
|
|
@if (item.checked) {
|
|
<ng-icon name="mynaCheck" size="0.85rem" class="text-primary" />
|
|
}
|
|
</span>
|
|
<span class="flex-1 truncate">{{ item.label }}</span>
|
|
@if (item.accelerator) {
|
|
<span class="shrink-0 text-muted-foreground">{{ item.accelerator }}</span>
|
|
}
|
|
</button>
|
|
}
|
|
}
|
|
</div>
|
|
}
|
|
`,
|
|
// Closing on scroll/blur matches native menu behavior: the anchor row moves
|
|
// out from under a menu that would otherwise float over stale content.
|
|
host: {
|
|
'(window:blur)': 'close()',
|
|
'(window:resize)': 'close()',
|
|
'(document:scroll)': 'close()',
|
|
},
|
|
})
|
|
export class ContextMenu {
|
|
readonly menu = inject(ContextMenuService);
|
|
private readonly focus = inject(FocusService);
|
|
private readonly menuEl = viewChild<ElementRef<HTMLDivElement>>('menuEl');
|
|
|
|
readonly activeIndex = signal(-1);
|
|
|
|
/** Click point, flipped/clamped so the menu never overflows the viewport. */
|
|
readonly position = computed(() => {
|
|
const req = this.menu.request();
|
|
if (!req) return { x: 0, y: 0 };
|
|
const height =
|
|
req.items.reduce((sum, i) => sum + (i.type === 'separator' ? SEPARATOR_H : ITEM_H), 0) + 8;
|
|
const x =
|
|
req.x + MENU_W > window.innerWidth - EDGE_GAP
|
|
? Math.max(EDGE_GAP, req.x - MENU_W)
|
|
: req.x;
|
|
const y =
|
|
req.y + height > window.innerHeight - EDGE_GAP
|
|
? Math.max(EDGE_GAP, req.y - height)
|
|
: req.y;
|
|
return { x, y };
|
|
});
|
|
|
|
constructor() {
|
|
// Focus the menu once it exists so arrow keys / Escape reach it. Keyed on
|
|
// the viewChild signal, NOT on request(): under zoneless CD an effect can
|
|
// run before the @if has rendered the element.
|
|
effect(() => this.menuEl()?.nativeElement.focus());
|
|
}
|
|
|
|
close(): void {
|
|
if (!this.menu.request()) return;
|
|
this.menu.close();
|
|
this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted
|
|
this.focus.bump();
|
|
}
|
|
|
|
pick(item: MenuItem): void {
|
|
if (!item.action) return;
|
|
const action = item.action;
|
|
this.close();
|
|
this.menu.onPick(action);
|
|
}
|
|
|
|
/** Right-clicking the backdrop closes rather than stacking a second menu. */
|
|
onBackdropContext(event: MouseEvent): void {
|
|
event.preventDefault();
|
|
this.close();
|
|
}
|
|
|
|
onKeydown(event: KeyboardEvent): void {
|
|
const items = this.menu.request()?.items ?? [];
|
|
switch (event.key) {
|
|
case 'Escape':
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.close();
|
|
break;
|
|
case 'ArrowDown':
|
|
event.preventDefault();
|
|
this.step(items, 1);
|
|
break;
|
|
case 'ArrowUp':
|
|
event.preventDefault();
|
|
this.step(items, -1);
|
|
break;
|
|
case 'Enter':
|
|
case ' ': {
|
|
event.preventDefault();
|
|
const item = items[this.activeIndex()];
|
|
if (item) this.pick(item);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Move the highlight by one selectable item, skipping separators, wrapping. */
|
|
private step(items: MenuItem[], delta: number): void {
|
|
const count = items.length;
|
|
if (!count) return;
|
|
let index = this.activeIndex();
|
|
for (let hops = 0; hops < count; hops++) {
|
|
index = (index + delta + count) % count;
|
|
if (items[index].type !== 'separator') {
|
|
this.activeIndex.set(index);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|