bun-app-window 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,6 +45,13 @@ export type TitleBarDocument = {
45
45
  body?: TitleBarElement;
46
46
  getElementById(id: string): TitleBarElement | null;
47
47
  querySelector(selector: string): TitleBarElement | null;
48
+ /**
49
+ * 可选:字体可用性探测,通常是 `document.fonts`。
50
+ *
51
+ * 缺省时 {@link detectIconSet} 按 `fluent` 处理(Win 10 / 11 都自带其中一个
52
+ * icon 字体)。传了就按探测结果选字形,两个字体都不在时退回 `unicode`。
53
+ */
54
+ fonts?: TitleBarFonts;
48
55
  };
49
56
 
50
57
  type EventLike = {
@@ -55,18 +62,60 @@ type EventLike = {
55
62
 
56
63
  export type TitleBarIconSet = "unicode" | "fluent";
57
64
 
65
+ /**
66
+ * Win 11 caption 字形所用的字体栈。
67
+ *
68
+ * `Segoe Fluent Icons` 是 Win 11 的,`Segoe MDL2 Assets` 是 Win 10 的,
69
+ * 而 `E921` / `E922` / `E923` / `E8BB` 这几个人码位在两者里**是同一个位置**——
70
+ * 所以同一份码位在两代系统上都能正确显示。这一点是照着
71
+ * `egoist/mygo` 的 `internal/windows/titlebar.go` 核实的:它同时支持 Win 10 / 11,
72
+ * 用的就是这一份码位表,只换字体名。
73
+ */
74
+ export const TITLEBAR_ICON_FONT_STACK = '"Segoe Fluent Icons", "Segoe MDL2 Assets"';
75
+
58
76
  /**
59
77
  * 按钮字形。
60
78
  *
61
- * `unicode` 是几何符号,任何字体都有,零依赖;
62
- * `fluent` 是 Segoe Fluent Icons 的私有区码位,观感更接近 Win 11 原生,
63
- * 但该字体缺失时会显示成豆腐块——所以不是默认值。
79
+ * `unicode` 是几何符号,任何字体都有,零依赖,但**撑不满 em 框**——
80
+ * 实测在 10px 字号下 `\u25A1` 的墨迹只有 5×4.5 逻辑 px,
81
+ * 而 Win 11 的 caption 字形是 10×10。这是「字形看起来偏小」的根因,不是字号问题。
82
+ *
83
+ * `fluent` 是 Segoe Fluent Icons / Segoe MDL2 Assets 的私有区码位,字形填满 em 框,
84
+ * 观感与 Win 11 原生一致。真正的风险只剩「两个字体都没有」的环境
85
+ * (非 Windows 宿主、极旧系统),那时 PUA 码位会渲染成豆腐块——
86
+ * 所以由 {@link detectIconSet} 探测,探测不到才退回 `unicode`。
64
87
  */
65
88
  export const TITLEBAR_GLYPHS = {
66
89
  unicode: { minimize: "\u2500", maximize: "\u25A1", restore: "\u2750", close: "\u2715" },
67
90
  fluent: { minimize: "\uE921", maximize: "\uE922", restore: "\uE923", close: "\uE8BB" },
68
91
  } as const;
69
92
 
93
+ /**
94
+ * 只问一件事的字体探测接口(`document.fonts` 的结构子集)。
95
+ *
96
+ * 单独抽出来而不是直接依赖 `FontFaceSet`:组件刻意不依赖 DOM 类型声明
97
+ * (`tsconfig` 的 `lib` 只有 ESNext),这一层最小接口既够用又能被测试注入。
98
+ */
99
+ export type TitleBarFonts = {
100
+ check(font: string): boolean;
101
+ };
102
+
103
+ /**
104
+ * 选字形集:优先 `fluent`,两个 icon 字体都不在时才退化到 `unicode`。
105
+ *
106
+ * 没有探测能力(`fonts` 缺省)时按 `fluent` 处理——目标平台是 Windows,
107
+ * Win 10 与 Win 11 都自带其中之一。冒险的只有「非 Windows 宿主 + 不传 fonts」,
108
+ * 那本来也不是这个组件的目标场景。
109
+ */
110
+ export function detectIconSet(fonts?: TitleBarFonts): TitleBarIconSet {
111
+ if (!fonts) {
112
+ return "fluent";
113
+ }
114
+ const hasFluent = fonts.check(`10px "Segoe Fluent Icons"`);
115
+ const hasMdl2 = fonts.check(`10px "Segoe MDL2 Assets"`);
116
+ return hasFluent || hasMdl2 ? "fluent" : "unicode";
117
+ }
118
+
70
119
  /**
71
120
  * 取某个按钮当前该显示的字形。
72
121
  *
@@ -111,10 +160,22 @@ export function titleBarCss(): string {
111
160
  --ws-tb-bg: #202020;
112
161
  --ws-tb-fg: #ffffff;
113
162
  --ws-tb-dim: rgba(255, 255, 255, 0.786);
114
- --ws-tb-hover: rgba(255, 255, 255, 0.0605);
115
- --ws-tb-active: rgba(255, 255, 255, 0.0419);
163
+ /* ---- 五个状态 ----------------------------------------------------------
164
+ Win 11 的 caption 背板是「前景色 + 一份 alpha」,不是固定灰;
165
+ 数值取自 Windows 自己的实现,与 egoist/mygo 的
166
+ internal/windows/titlebar.go(colors())一致:hover 0x1A、pressed 0x33。
167
+ 旧值 0.0605 / 0.0419 偏淡到几乎看不见悬停。
168
+ 失焦只降字形(0x72),禁用再降一档(0x5C);hot / pressed 优先于失焦。 */
169
+ --ws-tb-hover: rgba(255, 255, 255, 0.102);
170
+ --ws-tb-active: rgba(255, 255, 255, 0.200);
116
171
  --ws-tb-close-hover: #c42b1c;
117
172
  --ws-tb-close-active: #b02a1c;
173
+ --ws-tb-inactive: rgba(255, 255, 255, 0.447);
174
+ --ws-tb-disabled: rgba(255, 255, 255, 0.361);
175
+ /* 字形当前色。做成自定义属性而不是直接写 color,
176
+ 这样「失焦变淡」与「hover 恢复满强度」可以靠继承关系表达,
177
+ 不必给 hover 规则堆特异性去压过 [data-active="false"]。 */
178
+ --ws-tb-btn-fg: var(--ws-tb-fg);
118
179
  --ws-tb-border: rgba(255, 255, 255, 0.08);
119
180
 
120
181
  display: flex;
@@ -125,6 +186,17 @@ export function titleBarCss(): string {
125
186
  font: 400 12px/1 "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
126
187
  user-select: none;
127
188
  -webkit-user-select: none;
189
+ /* 下面四条**必须显式声明**。
190
+ 挂载点是 <header>,而消费方常写元素选择器「header { padding: ... }」做自己的页头。
191
+ 元素选择器的特异性低于类选择器,但只在组件**声明了**该属性时才被压住——
192
+ 没写的属性等于把控制权交给消费方。
193
+ 实测(baw-mvp):它的「header { padding: 12px 18px 10px }」漏进来,
194
+ 18px 右内边距把三个按钮整体推左 18 CSS px(@192dpi = 36 物理 px),
195
+ 12/10 的上下内边距又把 32px 的标题栏压成 9px 内容高,按钮跟着缩到 18 物理 px。
196
+ 两者合起来看起来像「字形没有居中」,而字形在按钮内其实是居中的。 */
197
+ margin: 0;
198
+ padding: 0;
199
+ gap: 0;
128
200
  border-bottom: 1px solid var(--ws-tb-border);
129
201
  box-sizing: border-box;
130
202
  }
@@ -133,11 +205,18 @@ export function titleBarCss(): string {
133
205
  --ws-tb-bg: #f3f3f3;
134
206
  --ws-tb-fg: #000000;
135
207
  --ws-tb-dim: rgba(0, 0, 0, 0.6063);
136
- --ws-tb-hover: rgba(0, 0, 0, 0.0373);
137
- --ws-tb-active: rgba(0, 0, 0, 0.0241);
208
+ --ws-tb-hover: rgba(0, 0, 0, 0.102);
209
+ --ws-tb-active: rgba(0, 0, 0, 0.200);
210
+ --ws-tb-inactive: rgba(0, 0, 0, 0.447);
211
+ --ws-tb-disabled: rgba(0, 0, 0, 0.361);
138
212
  --ws-tb-border: rgba(0, 0, 0, 0.0578);
139
213
  }
140
214
 
215
+ /* 窗口失焦:只降字形,背板照旧(hover / pressed 仍然优先,见下) */
216
+ .ws-titlebar[data-active="false"] {
217
+ --ws-tb-btn-fg: var(--ws-tb-inactive);
218
+ }
219
+
141
220
  .ws-titlebar__lead {
142
221
  display: flex;
143
222
  align-items: center;
@@ -170,14 +249,31 @@ export function titleBarCss(): string {
170
249
  flex: none;
171
250
  }
172
251
 
252
+ /* ---- 原生 caption(FLAG_NATIVE_CAPTIONS)----------------------------------
253
+ 三个按钮由壳层画在一个 layered 子窗口里、盖在窗口右上角。页面这边要做的唯一
254
+ 一件事是**把位置留出来**——否则标题文字会被那层子窗口压住。
255
+ 宽度是 46 × 3,与 .ws-titlebar__btn 的 width 同源(后者由
256
+ tests/types.test.ts 钉在 CAPTION_BUTTON_WIDTH 上)。
257
+ 为什么不用 padding 而是给控件容器定宽:按钮区是「贴住客户区右边缘」的,
258
+ 用容器定宽更直白,也不会与标题栏自己的 padding 相互影响。 */
259
+ .ws-titlebar[data-captions="native"] .ws-titlebar__controls {
260
+ width: calc(46px * 3);
261
+ }
262
+
173
263
  .ws-titlebar__btn {
174
264
  width: 46px;
175
265
  height: 100%;
266
+ /* margin 必须显式清零:消费方常写「button { margin: ... }」之类,
267
+ 元素选择器只在组件没声明该属性时生效。与 .ws-titlebar 那一组同理。 */
268
+ margin: 0;
176
269
  border: 0;
177
270
  padding: 0;
178
271
  background: transparent;
179
- color: var(--ws-tb-dim);
272
+ color: var(--ws-tb-btn-fg);
180
273
  font: inherit;
274
+ /* Win 11 的 caption 字形是 10 DIP。在这个字号下,
275
+ Fluent 图标字体把 10×10 逻辑 px 填满(实测墨迹填满 em 框),
276
+ 而几何符号的墨迹只占 5×4.5——所以换字体才是「字形偏小」的解。 */
181
277
  font-size: 10px;
182
278
  line-height: 1;
183
279
  display: grid;
@@ -187,7 +283,7 @@ export function titleBarCss(): string {
187
283
  }
188
284
 
189
285
  .ws-titlebar__btn:hover { background: var(--ws-tb-hover); color: var(--ws-tb-fg); }
190
- .ws-titlebar__btn:active { background: var(--ws-tb-active); }
286
+ .ws-titlebar__btn:active { background: var(--ws-tb-active); color: var(--ws-tb-fg); }
191
287
 
192
288
  .ws-titlebar__btn--close:hover { background: var(--ws-tb-close-hover); color: #ffffff; }
193
289
  .ws-titlebar__btn--close:active { background: var(--ws-tb-close-active); color: #ffffff; }
@@ -197,8 +293,22 @@ export function titleBarCss(): string {
197
293
  outline-offset: -2px;
198
294
  }
199
295
 
296
+ /* 禁用:字形再淡一档,且不响应悬停 / 按下。Win 11 用 aria-disabled 语义即可,
297
+ 不必另造一个属性名。 */
298
+ .ws-titlebar__btn[aria-disabled="true"] {
299
+ color: var(--ws-tb-disabled);
300
+ pointer-events: none;
301
+ }
302
+ .ws-titlebar__btn[aria-disabled="true"]:hover,
303
+ .ws-titlebar__btn[aria-disabled="true"]:active {
304
+ background: transparent;
305
+ color: var(--ws-tb-disabled);
306
+ }
307
+
200
308
  .ws-titlebar[data-icons="fluent"] .ws-titlebar__btn {
201
- font-family: "Segoe Fluent Icons", "Segoe MDL2 Assets", sans-serif;
309
+ font-family: ${TITLEBAR_ICON_FONT_STACK}, sans-serif;
310
+ /* 图标字体的字形是等宽方框,不要被消费方的 letter-spacing 拉开 */
311
+ letter-spacing: 0;
202
312
  }
203
313
  `.trim();
204
314
  }
@@ -212,8 +322,26 @@ export type TitleBarOptions = {
212
322
  bridge: TitleBarBridge;
213
323
  /** 初始是否已最大化 */
214
324
  maximized?: boolean;
215
- /** 按钮字形,默认 `unicode` */
325
+ /** 窗口初始是否处于激活状态(决定字形是否变淡)。缺省 `true` */
326
+ active?: boolean;
327
+ /** 按钮字形,缺省由 {@link detectIconSet} 按字体可用性决定(通常 `fluent`) */
216
328
  icons?: TitleBarIconSet;
329
+ /**
330
+ * 三个 caption 按钮由谁画。
331
+ *
332
+ * - `"dom"`(缺省):组件自己渲染按钮,行为完全由 `bridge` 决定
333
+ * - `"native"`:**不渲染按钮**,只在右侧留出 `46 × 3` 的空白,
334
+ * 由壳层的 `FLAG_NATIVE_CAPTIONS` 把按钮画上去
335
+ *
336
+ * 什么时候要 `"native"`:需要 Snap Layouts 的时候。`WM_NCHITTEST` 走光标下
337
+ * 最深那层,而 WebView2 的渲染子窗口在**别的进程**里、返回 `HTCLIENT`,
338
+ * 页面画的按钮永远拿不到 `HTMAXBUTTON`(`pitfalls` 2.14)。壳层会盖一个
339
+ * 自己的子窗口在按钮位置上,命中就落在壳层手里。
340
+ *
341
+ * 两者必须配套:壳层开了 `FLAG_NATIVE_CAPTIONS` 而这里仍用 `"dom"`,
342
+ * 会看到两套按钮叠在一起;反过来则是一块点不到的空区。
343
+ */
344
+ captions?: "dom" | "native";
217
345
  /** 主题,缺省跟随 `prefers-color-scheme` */
218
346
  theme?: "light" | "dark";
219
347
  /** 挂载容器,缺省 `document.body` */
@@ -228,6 +356,23 @@ export type TitleBarHandle = {
228
356
  setMaximized(maximized: boolean): void;
229
357
  /** 组件最后一次被设置的最大化状态。用于核对与宿主推送是否同步 */
230
358
  isMaximized(): boolean;
359
+ /**
360
+ * 窗口是否处于激活(聚焦)状态。失焦时字形降到 45% 不透明度,
361
+ * 这是 Win 11 与「永远满强度」的一处可见差别。
362
+ *
363
+ * 接 `windowState` 推送里的 `focused`:用户点别的窗口时它会变,
364
+ * 页面自己看不到这件事。
365
+ */
366
+ setActive(active: boolean): void;
367
+ /** 组件最后一次被设置的激活状态 */
368
+ isActive(): boolean;
369
+ /**
370
+ * 按 Win 11 的规则禁用某个按钮。
371
+ *
372
+ * 用途是「这个窗口不能最大化」这类情形——禁用后字形变淡、且不再派发动作。
373
+ * 用 `aria-disabled` 而不是另造属性:它同时是给辅助技术的语义。
374
+ */
375
+ setEnabled(action: "minimize" | "maximize" | "close", enabled: boolean): void;
231
376
  setTheme(theme: "light" | "dark"): void;
232
377
  /** 移除 DOM 与事件监听。样式表保留(可能是别的实例注入的) */
233
378
  destroy(): void;
@@ -275,7 +420,9 @@ function inControls(target: unknown): boolean {
275
420
  */
276
421
  export function mountTitleBar(opts: TitleBarOptions): TitleBarHandle {
277
422
  const doc = resolveDocument(opts.document);
278
- const icons: TitleBarIconSet = opts.icons ?? "unicode";
423
+ // 缺省按字体探测:Win 10 / 11 都有 icon 字体,看到的就是原生字形;
424
+ // 两个都没有才退回几何符号(`unicode`)。
425
+ const icons: TitleBarIconSet = opts.icons ?? detectIconSet(doc.fonts);
279
426
 
280
427
  // 样式只注入一次。多个标题栏实例(多窗口页面)共用同一份
281
428
  if (!doc.getElementById(STYLE_ID)) {
@@ -288,6 +435,14 @@ export function mountTitleBar(opts: TitleBarOptions): TitleBarHandle {
288
435
  const root = doc.createElement("header");
289
436
  root.className = "ws-titlebar";
290
437
  root.setAttribute("data-icons", icons);
438
+ // 缺省按激活处理:窗口刚创建时就是前台,写成 false 会让标题栏一开始是灰的。
439
+ root.setAttribute("data-active", opts.active === false ? "false" : "true");
440
+ // 原生 caption 模式:不渲染按钮,只在右侧留位(CSS 按这个属性选择器定宽)。
441
+ // 属性只在 native 时出现,所以缺省形态的 DOM 与引入本位之前**逐字相同**。
442
+ const nativeCaptions = opts.captions === "native";
443
+ if (nativeCaptions) {
444
+ root.setAttribute("data-captions", "native");
445
+ }
291
446
  if (opts.theme) {
292
447
  root.setAttribute("data-theme", opts.theme);
293
448
  }
@@ -318,16 +473,21 @@ export function mountTitleBar(opts: TitleBarOptions): TitleBarHandle {
318
473
  controls.setAttribute("data-ws-tb-controls", "");
319
474
 
320
475
  const buttons: Partial<Record<"minimize" | "maximize" | "close", TitleBarElement>> = {};
321
- for (const action of ["minimize", "maximize", "close"] as const) {
322
- const btn = doc.createElement("button");
323
- btn.className =
324
- action === "close" ? "ws-titlebar__btn ws-titlebar__btn--close" : "ws-titlebar__btn";
325
- btn.setAttribute("type", "button");
326
- btn.setAttribute("data-act", action);
327
- btn.textContent = glyphFor(icons, action, opts.maximized === true);
328
- btn.setAttribute("aria-label", labelFor(action, opts.maximized === true));
329
- buttons[action] = btn;
330
- controls.appendChild(btn);
476
+ // `captions: "native"` 时一个按钮都不建:那三个由壳层的 layered 子窗口画,
477
+ // 页面上再画一份就是两套叠在一起。控件容器仍然保留——它是「留位」的载体
478
+ // (CSS 给它 46×3 的宽度),也是拖拽判定里 `inControls` 的锚点。
479
+ if (!nativeCaptions) {
480
+ for (const action of ["minimize", "maximize", "close"] as const) {
481
+ const btn = doc.createElement("button");
482
+ btn.className =
483
+ action === "close" ? "ws-titlebar__btn ws-titlebar__btn--close" : "ws-titlebar__btn";
484
+ btn.setAttribute("type", "button");
485
+ btn.setAttribute("data-act", action);
486
+ btn.textContent = glyphFor(icons, action, opts.maximized === true);
487
+ btn.setAttribute("aria-label", labelFor(action, opts.maximized === true));
488
+ buttons[action] = btn;
489
+ controls.appendChild(btn);
490
+ }
331
491
  }
332
492
 
333
493
  root.append(lead, controls);
@@ -347,6 +507,24 @@ export function mountTitleBar(opts: TitleBarOptions): TitleBarHandle {
347
507
  /** 当前是否最大化。供调用方在本地校验状态是否同步 */
348
508
  const isMaximized = (): boolean => maximizedState;
349
509
 
510
+ let activeState = opts.active !== false;
511
+
512
+ /** 当前是否处于激活状态 */
513
+ const isActive = (): boolean => activeState;
514
+
515
+ const applyActive = (next: boolean): void => {
516
+ activeState = next;
517
+ root.setAttribute("data-active", next ? "true" : "false");
518
+ };
519
+
520
+ const applyEnabled = (action: "minimize" | "maximize" | "close", enabled: boolean): void => {
521
+ const btn = buttons[action];
522
+ if (!btn) {
523
+ return;
524
+ }
525
+ btn.setAttribute("aria-disabled", enabled ? "false" : "true");
526
+ };
527
+
350
528
  const onPointerDown = (ev: EventLike): void => {
351
529
  if (ev.button !== undefined && ev.button !== 0) {
352
530
  return;
@@ -368,6 +546,11 @@ export function mountTitleBar(opts: TitleBarOptions): TitleBarHandle {
368
546
  const onControlsClick = (ev: EventLike): void => {
369
547
  const node = ev.target as { closest?: (s: string) => { getAttribute(n: string): string | null } | null } | null;
370
548
  const btn = typeof node?.closest === "function" ? node.closest("[data-act]") : null;
549
+ // 禁用的按钮不派发动作。CSS 已经用 pointer-events: none 拦住鼠标,
550
+ // 但键盘(Tab 到按钮上按 Enter/Space)仍会走到这里,所以判定必须在这里也做一次。
551
+ if (btn?.getAttribute("aria-disabled") === "true") {
552
+ return;
553
+ }
371
554
  const act = btn?.getAttribute("data-act");
372
555
  if (act === "minimize") {
373
556
  opts.bridge.minimize();
@@ -398,6 +581,9 @@ export function mountTitleBar(opts: TitleBarOptions): TitleBarHandle {
398
581
  },
399
582
  setMaximized: applyMaximized,
400
583
  isMaximized,
584
+ setActive: applyActive,
585
+ isActive,
586
+ setEnabled: applyEnabled,
401
587
  setTheme(theme: "light" | "dark"): void {
402
588
  root.setAttribute("data-theme", theme);
403
589
  },