bun-app-window 0.6.0 → 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.
- package/CHANGELOG.md +101 -2
- package/dist/libshell.dll +0 -0
- package/package.json +1 -1
- package/src/shell/state.ts +32 -0
- package/src/shell/types.ts +93 -0
- package/src/ui/titlebar.ts +208 -22
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,105 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## [0.6.1] - 2026-10-22
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **The caption buttons now draw Win 11's actual glyphs at Win 11's actual size, and the component has all five states instead of two.** The previous defaults were two independent misses that compounded.
|
|
14
|
+
|
|
15
|
+
**The glyphs were wrong for the platform, not just "less pretty".** `TITLEBAR_GLYPHS.unicode` renders `─ □ ❐ ✕` — geometric symbols from whatever text font is loaded. Their ink fills about half the em box: measured on `baw-mvp` at @192dpi, `\u25A1` came out **10×9 physical** and `\u25B1` **16×2**, against Win 11's **20×20** and **20×2** (measured on the Settings app, same machine). The fix is not a bigger `font-size` — `10px` was already correct — it is the font. `TITLEBAR_GLYPHS.fluent` holds `E921` / `E922` / `E923` / `E8BB`, which `Segoe Fluent Icons` (Win 11) and `Segoe MDL2 Assets` (Win 10) draw at **the same code points**, so one table covers both systems.
|
|
16
|
+
|
|
17
|
+
`fluent` was previously opt-in because a missing font renders private-use code points as tofu. `detectIconSet()` now checks `document.fonts` for either face and only falls back to `unicode` when neither is present; with no `FontFaceSource` to ask, it assumes `fluent`, since the component targets Windows. The result on `baw-mvp`: **20×20 / 20×20 / 20×2 physical**, identical to the system's own buttons, with horizontal centring at 0.0 px.
|
|
18
|
+
|
|
19
|
+
**Two states became five.** Hover and pressed backplates move from `0.0605` / `0.0419` to **`0.102` / `0.200`** of the foreground colour — the old values were faint enough that hovering looked like nothing happened. `setActive()` dims the glyph to `0.447` when the window loses focus, and `setEnabled(action, enabled)` drops it to `0.361` and stops dispatching. All four numbers, and the close button's red, come from Windows' own implementation as cross-checked against `egoist/mygo`'s `internal/windows/titlebar.go` (`colors()`).
|
|
20
|
+
|
|
21
|
+
**Focus dimming is expressed as a custom property, not a specificity fight.** `.ws-titlebar__btn` sets `color: var(--ws-tb-btn-fg)`, and `[data-active="false"]` on the root re-points that variable. Writing `color: var(--ws-tb-inactive)` straight onto the button would have lost to the `:hover` rule below it, and the fix for that would have been `:not()` chains or `!important` — the kind of thing that works until the next state arrives.
|
|
22
|
+
|
|
23
|
+
`setEnabled` guards in **two** places on purpose: CSS `pointer-events: none` stops the mouse, but a keyboard user tabbing to the button and pressing Enter still reaches the click handler. The `aria-disabled` attribute is the single source both read.
|
|
24
|
+
|
|
25
|
+
Verification: 38 assertions in `tests/titlebar.test.ts` (up from 25), including `detectIconSet` in all four combinations, the five-state CSS values asserted **inside their rule blocks**, and the disabled button not firing. Reverse-verified by restoring `--ws-tb-hover: 0.0605` — exactly one assertion went red, the rest stayed green.
|
|
26
|
+
|
|
27
|
+
**Still manual:** whether the focus-dimmed glyph actually renders dimmer on screen. The check needs the window to lose foreground, and in this session `SetForegroundWindow` never took it away from the host chat window — both readings came back at full brightness, i.e. the window never left the active state, so the probe measured nothing. The logic is covered by unit tests and the CSS is a plain attribute selector, but the end-to-end path (native `focused` bit → `windowState` push → `setActive` → repaint) has not been observed.
|
|
28
|
+
|
|
29
|
+
- **Caption buttons on a frameless window now answer `HTMAXBUTTON`, so Windows 11 Snap Layouts works.** The only documented trigger is `WM_NCHITTEST` returning `HTMAXBUTTON` for the maximize/restore button (*Support snap layouts for desktop apps on Windows 11*). Previously `hit_test_client` returned only the eight resize codes and `HTCLIENT`, so hovering maximize never opened the layout picker — the last piece of "looks like a Win 11 app" that CSS cannot buy.
|
|
30
|
+
|
|
31
|
+
Three constants became a single source of truth in `types.ts` — `CAPTION_BAR_HEIGHT` (32), `CAPTION_BUTTON_WIDTH` (46), `CAPTION_BUTTONS` (`close` / `maximize` / `minimize`, right to left). Two copies follow it: `kCaptionBarHeight` / `kCaptionButtonWidth` plus the `caption_button_at()` order in `native/shell.cc` (C++ cannot import TS), and the `32px` / `46px` in `titlebar.css`.
|
|
32
|
+
|
|
33
|
+
**Order matters more than the numbers.** If the hit-test order and the DOM order disagree, clicking "close" maximizes the window — and the buttons look identical, so nothing about the symptom points at a constant.
|
|
34
|
+
|
|
35
|
+
**`HTMAXBUTTON` cannot ship alone.** Once `WM_NCHITTEST` answers that code, the point is non-client: a press delivers `WM_NCLBUTTONDOWN` instead of `WM_LBUTTONDOWN`, so the page receives no pointer or click event at all. Changing only the hit test turns the button into a black hole that eats clicks — the symptom reads as "clicking maximize does nothing", which is exactly backwards from the intent. `shell_wnd_proc` therefore handles `WM_NCLBUTTONDOWN` for the three button codes and performs the action itself; every other code still falls through to `DefWindowProc` so caption dragging and edge resizing keep working.
|
|
36
|
+
|
|
37
|
+
The caption check is deliberately placed **before** the resize-border check and is **not** gated on `g_resizable` / `IsZoomed`. The old guard opened with `!g_frameless || !g_resizable || IsZoomed(hwnd) → HTCLIENT`; reusing it would make the maximize button unhittable precisely when the window is maximized — which is when the user needs it to restore.
|
|
38
|
+
|
|
39
|
+
Measured with a one-off FFI probe against `dist/libshell.dll` and `user32.dll` (7/7):
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
A. scan from the right edge (physical px, 200% scale)
|
|
43
|
+
x=1799 → HTCLOSE
|
|
44
|
+
x=1708 → HTMAXBUTTON
|
|
45
|
+
x=1616 → HTMINBUTTON
|
|
46
|
+
x=1524 → HTTOP ← inside the resize border
|
|
47
|
+
button width 91/92, expected 92 (= CSS 46px × scale)
|
|
48
|
+
|
|
49
|
+
B. WM_NCLBUTTONDOWN
|
|
50
|
+
HTMAXBUTTON → maximized state=1
|
|
51
|
+
again → restored state=0
|
|
52
|
+
HTMINBUTTON → minimized state=2, win=314x50 (sentinel — really minimized)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Still manual**: whether the Snap flyout actually *appears* on hover. It is a DWM-owned popup, so `PrintWindow` cannot capture it and no readout exposes it. Automating the hover was attempted and blocked — the Windows MCP refused the cursor move (`USER_CONTROL`, `state: user`), and `SetCursorPos` landed 216 px away, i.e. the pointer was in use. What is verified is the precondition the documentation names; what is not verified is the flyout itself.
|
|
56
|
+
|
|
57
|
+
One measurement worth keeping: the outer 13 physical pixels of the window are the resize border, so the title bar's top ~6.5 logical pixels answer `HTTOP` rather than the button code. Caption buttons win where they overlap (they are checked first), but a probe that samples `y=1` will read `HTTOP` and should not be read as "the button is missing".
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- **The caption buttons sat 18 CSS px left of where they belong, and the title bar was squeezed to a third of its height — because a consumer's `header { … }` rule leaked into the component.** The component mounts on `<header class="ws-titlebar">`, and `baw-mvp`'s page has an element-selector rule for its own page header: `header { padding: 12px 18px 10px; gap: 12px }`. `.ws-titlebar` never declared `padding`, so the 18px right padding pushed all three buttons left by 18 CSS px (measured: 36 physical px @192dpi), and under `box-sizing: border-box` the 12/10 vertical padding squeezed the 32px bar down to 9px of content, shrinking the buttons to 18 physical px tall.
|
|
62
|
+
|
|
63
|
+
**It reads as "the glyph is not centred", and that diagnosis is wrong.** Measuring the hover backplate — `background` covers the whole button, so its edges *are* the button's edges — put the backplate at 92 physical px wide with the glyph's centre and the backplate's centre both at `d=137.5`: the glyph was centred all along. The whole group was displaced, then squashed.
|
|
64
|
+
|
|
65
|
+
**Specificity is not the mechanism.** An element selector (`0,0,1`) does lose to a class selector (`0,1,0`) — but only where the component *declares* the property. An undeclared property does not compete at all, so it is handed to the consumer. The mounting tag is part of the public contract, and `<header>` / `<button>` / `<div>` are exactly the tags consumers write global rules for.
|
|
66
|
+
|
|
67
|
+
The fix declares `margin` / `padding` / `gap` on `.ws-titlebar` and `margin` on `.ws-titlebar__btn`. The consumer sides changes too: `header { … }` becomes `.app-header { … }`, and `button { … }` becomes `button:not(.ws-titlebar__btn)` — the latter also stops `button:active { transform: translateY(1px) }` from reaching the caption buttons, which had been sinking the glyph 1px on press where Win 11 only swaps the backplate.
|
|
68
|
+
|
|
69
|
+
Three assertions guard it in `tests/titlebar.test.ts`, and they must be made **inside the rule block** (`cssBlock(css, ".ws-titlebar")` before `toContain("padding: 0")`). Writing `expect(css).toContain("padding: 0")` would be satisfied by the identically-valued declaration already in `.ws-titlebar__btn` — the guard would silently become a tautology, and this stylesheet happens to contain exactly that coincidence.
|
|
70
|
+
|
|
71
|
+
Verified by reverse: dropping `padding: 0` from `.ws-titlebar` turned **exactly one** assertion red and left the other 24 green; restoring it gave 25/25. On a real window the centring offsets went from `-35.5 / -36.0 / -36.0` to `0.5 / 0.0 / 0.0` physical px, and the close button now ends flush with the client area's right edge.
|
|
72
|
+
|
|
73
|
+
- **`getBounds()` returned the Win32 minimised sentinel rectangle, and `attachPlacementStore` wrote it straight to disk.** `format_bounds()` only took `WINDOWPLACEMENT.rcNormalPosition` when `IsZoomed()` was true. A minimised window is not zoomed, so it fell through to `GetWindowRect()` — which for a minimised window reports `-32000,-32000` plus an icon-sized box (measured: `314x50`). `IsIconic()` was never consulted.
|
|
74
|
+
|
|
75
|
+
Those values look entirely valid: integer coordinates, positive dimensions. The existing shape checks (finite numbers, size > 0) cannot distinguish them from a real rectangle. So `window.json` became `{"x":-32000,"y":-32000,"width":314,"height":50,"dpi":192}`, and the next launch restored a 314×50 window — observed on `baw-mvp` as a 666×471 window with the page wrapped and scrolling.
|
|
76
|
+
|
|
77
|
+
Fixed at two layers:
|
|
78
|
+
|
|
79
|
+
- **Native.** The condition is now "minimised **or** maximised" instead of "maximised", and `IsIconic()` is explicit. The `maximized` bit comes from `WPF_RESTORETOMAXIMIZED` while minimised, because `IsZoomed()` is always false there — without it, "maximise → minimise → close" silently dropped the maximised state.
|
|
80
|
+
- **`state.ts`.** `parseBounds()` and `parsePlacement()` reject any coordinate at or below `-30000`. The first stops new bad values being recorded; the second invalidates files already written by earlier versions, so upgrading repairs the existing bad state rather than only preventing new ones. The threshold is a range, not an exact `-32000`, because the sentinel carries a few pixels of offset depending on DPI and theme. It cannot collide with real coordinates: an 8K display is 7680 wide, and even a long multi-monitor row stays far below 30000.
|
|
81
|
+
|
|
82
|
+
Three tests added, and verified by removing the guard — exactly those three go red, including the `-31996` offset variant. The companion cases that must keep passing are pinned too: a legitimate negative `x` (secondary monitor to the left) still parses.
|
|
83
|
+
|
|
84
|
+
Verified on a real window with a one-off FFI probe against `dist/libshell.dll` (bypassing every TS wrapper):
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
normal → 147,147,1826,1271,0,192
|
|
88
|
+
minimised → 147,147,1826,1271,0,192 ← no sentinel, restore rect
|
|
89
|
+
maximised → 147,147,1826,1271,1,192
|
|
90
|
+
maximised → minimise → 147,147,1826,1271,1,192 ← maxed bit preserved
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- **The caption buttons are now drawn and hit-tested by the shell on its own layered child window, so a consumer can stop faking them with DOM.** `FLAG_NATIVE_CAPTIONS` (512) creates a `WS_EX_LAYERED | WS_CHILD` window named `webview_shell_caption`, anchored to the client area's top-right corner at exactly `3 × CAPTION_BUTTON_WIDTH` by `CAPTION_BAR_HEIGHT` in logical pixels (`276×64` physical at @192dpi). It answers `WM_NCHITTEST` itself with `HTCLOSE` / `HTMAXBUTTON` / `HTMINBUTTON`; the title strip and the client area still report `Chrome_RenderWidgetHostHWND`, so the page keeps receiving mouse input. `mountTitleBar({ captions: "native" })` skips creating the three buttons and only reserves their footprint.
|
|
94
|
+
|
|
95
|
+
It supersedes `FLAG_TITLEBAR_RESERVE` for consumers that draw their own title bar. `baw-mvp` now runs `flags = 573`; the DLL grows to `1,025,536 B` with its 34 exports unchanged, and `gdi32` joins the subsystem library list (ten now).
|
|
96
|
+
|
|
97
|
+
Three details it depends on, each pinned by an assertion in `tests/nav-guard.test.ts`:
|
|
98
|
+
|
|
99
|
+
- **`UpdateLayeredWindow` must be given an explicit `SIZE`.** Passing `nullptr` returns `true`, sets no error, and leaves the surface `0×0` — the window then exists, is visible, sits at the top of the child chain, has a correct rectangle, and is hit-transparent anyway. The window under it receives every click. See `docs/pitfalls.md` 2.21 for the A/B and for why the documented "the size does not change" does not cover "never was set".
|
|
100
|
+
- **The rest fill is `alpha = 1`, not 0.** A fully transparent layered window is hit-transparent: measured 0/3 hits at `alpha = 0`, 3/3 for every value from 1 to 255. Rest state is invisible at 1/255 either way, so the value exists only to keep the window hittable.
|
|
101
|
+
- **`WM_NCMOUSEMOVE` breaks to `DefWindowProcW` instead of returning 0.** That is where the Snap hover detection happens; short-circuiting it costs the layout picker while leaving the window looking completely normal.
|
|
102
|
+
|
|
103
|
+
Clicks go through `WM_SYSCOMMAND` from the shell process, and only fire when released over the same button. Once `WM_NCHITTEST` answers `HTMAXBUTTON` the point is non-client — the press arrives as `WM_NCLBUTTONDOWN`, not `WM_LBUTTONDOWN` — so the page never sees a `click` and the shell has to perform the action itself. This is also why the three buttons are children of the shell rather than elements of the page: a DOM button cannot return a non-client hit code.
|
|
104
|
+
|
|
105
|
+
Measured on `baw-mvp` at @192dpi maximised: `WindowFromPoint` lands on `webview_shell_caption` 3/3, the three hit codes are exact, glyph boxes are `20×20 / 20×20 / 20×2` physical (`10×10 / 10×10 / 10×1` logical) with `0.0` centring offsets, and the hover backplate moves `32.0 → 55.1` (`+23.1`, against `+22.7` expected). Gate: `72 files`, `603 pass / 0 fail`, `VERIFY-PACK PASSED (7 checks)`.
|
|
106
|
+
|
|
107
|
+
**Snap Layouts was confirmed by a real mouse, and that is the interesting part.** Four automated probes reported zero new top-level windows while hovering and were read as "a child window cannot trigger Snap" — which was wrong. The flyout is a DWM-owned popup, so window enumeration never sees it and the probe could not have reported anything else. The rule this leaves behind (`docs/pitfalls.md` 2.21, fourth section): a guard that reports `0` must first prove it is not blind, by reporting non-zero on a sample known to be true.
|
|
10
108
|
|
|
11
109
|
## [0.6.0] - 2026-10-21
|
|
12
110
|
|
|
@@ -532,7 +630,8 @@ First public release.
|
|
|
532
630
|
- Not yet verified in managed environments that enforce AppLocker, WDAC, or third-party EDR behaviour blocking.
|
|
533
631
|
- No commercially trusted code signing: only a self-signed certificate has been exercised end to end.
|
|
534
632
|
|
|
535
|
-
[Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.6.
|
|
633
|
+
[Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.6.1...HEAD
|
|
634
|
+
[0.6.1]: https://github.com/TiancongLx/bun-app-window/compare/v0.6.0...v0.6.1
|
|
536
635
|
[0.6.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.5.1...v0.6.0
|
|
537
636
|
[0.5.1]: https://github.com/TiancongLx/bun-app-window/compare/v0.5.0...v0.5.1
|
|
538
637
|
[0.5.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.4.1...v0.5.0
|
package/dist/libshell.dll
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "bun-app-window",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.1",
|
|
5
5
|
"description": "Native WebView2 window shell for Bun — frameless or system-decorated windows, DPI-aware geometry, typed IPC, system-accurate title bar",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
package/src/shell/state.ts
CHANGED
|
@@ -50,6 +50,30 @@ const DEFAULTS = {
|
|
|
50
50
|
pumpMs: 16,
|
|
51
51
|
} as const;
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Win32 的「窗口已最小化」哨兵坐标。
|
|
55
|
+
*
|
|
56
|
+
* 窗口最小化时 `GetWindowRect` 返回的不是真实位置,而是一对哨兵值——实测
|
|
57
|
+
* `-32000,-32000` 加一个图标尺寸(典型 `314x50`)。它们**看起来完全合法**:
|
|
58
|
+
* 坐标是整数,尺寸也大于 0,所以只判「非有限 / 尺寸 ≤ 0」的形状校验拦不住。
|
|
59
|
+
*
|
|
60
|
+
* 成因在原生 `format_bounds`:它只在 `IsZoomed` 为真时走
|
|
61
|
+
* `WINDOWPLACEMENT.rcNormalPosition`,最小化时 `IsZoomed` 返回 false,于是落进
|
|
62
|
+
* `GetWindowRect` 分支——**`IsIconic` 从未被判断**。于是这对哨兵值会一路写成
|
|
63
|
+
* `{"x":-32000,"y":-32000,"width":314,"height":50}`,下次启动恢复成一个畸形小窗
|
|
64
|
+
* (实测:666×471,页面被挤到换行并出现滚动条)。
|
|
65
|
+
*
|
|
66
|
+
* 阈值取 -30000 而不是精确 -32000:不同 DPI / 主题下这对值会带几个像素偏移。
|
|
67
|
+
* 真实坐标到不了这个量级——8K 屏横向也只有 7680,多屏横排也远不到 30000,
|
|
68
|
+
* 所以用阈值不会误伤合法的负坐标(主屏在右、副屏在左时 x 确实是负数)。
|
|
69
|
+
*/
|
|
70
|
+
const MINIMIZED_SENTINEL = -30000;
|
|
71
|
+
|
|
72
|
+
/** 是不是 Win32 的最小化哨兵坐标。任一维命中即判否——两维是同时写的 */
|
|
73
|
+
function isMinimizedSentinel(x: number, y: number): boolean {
|
|
74
|
+
return x <= MINIMIZED_SENTINEL || y <= MINIMIZED_SENTINEL;
|
|
75
|
+
}
|
|
76
|
+
|
|
53
77
|
/** 把原生返回的一行文本解析成 WindowPlacement;格式不对返回 null。 */
|
|
54
78
|
export function parseBounds(text: string): WindowPlacement | null {
|
|
55
79
|
const parts = text.trim().split(",");
|
|
@@ -72,6 +96,10 @@ export function parseBounds(text: string): WindowPlacement | null {
|
|
|
72
96
|
if (width <= 0 || height <= 0 || dpi <= 0) {
|
|
73
97
|
return null;
|
|
74
98
|
}
|
|
99
|
+
// 最小化时原生给的是哨兵坐标,不是真实位置——宁可返回 null 让调用方保留上一次的值
|
|
100
|
+
if (isMinimizedSentinel(x, y)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
75
103
|
return { x, y, width, height, maximized: maximized !== 0, dpi };
|
|
76
104
|
}
|
|
77
105
|
|
|
@@ -108,6 +136,10 @@ export function parsePlacement(raw: unknown): WindowPlacement | null {
|
|
|
108
136
|
if (Math.abs(x) > 200000 || Math.abs(y) > 200000) {
|
|
109
137
|
return null;
|
|
110
138
|
}
|
|
139
|
+
// 已写进磁盘的哨兵坐标同样不可用:旧版本写过这种文件,读的时候要判否而不是恢复它
|
|
140
|
+
if (isMinimizedSentinel(x, y)) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
111
143
|
return { x, y, width, height, maximized: o.maximized === true, dpi };
|
|
112
144
|
}
|
|
113
145
|
|
package/src/shell/types.ts
CHANGED
|
@@ -72,6 +72,68 @@ export const FLAG_START_MAXIMIZED = 64;
|
|
|
72
72
|
* `javascript:`、`ms-*:` 以及自定义 scheme 一律静默取消,与本位无关。
|
|
73
73
|
*/
|
|
74
74
|
export const FLAG_ALLOW_EXTERNAL_NAV = 128;
|
|
75
|
+
/**
|
|
76
|
+
* 顶部让出一条给自绘标题栏:把 WebView2 的容器子窗口整体下移 caption 高度(32 逻辑像素)。
|
|
77
|
+
*
|
|
78
|
+
* ## 为什么需要它(命中测试的硬约束,不是布局偏好)
|
|
79
|
+
*
|
|
80
|
+
* WebView2 的容器子窗口(widget)铺满整个客户区,它内部还有三层 Chrome 窗口,
|
|
81
|
+
* 而**真实鼠标的 `WM_NCHITTEST` 走光标下最深那层**:
|
|
82
|
+
*
|
|
83
|
+
* ```text
|
|
84
|
+
* webview_widget → Chrome_WidgetWin_0 → Chrome_WidgetWin_1
|
|
85
|
+
* → Chrome_RenderWidgetHostHWND → Intermediate D3D Window
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* 后三层在**别的进程**里(实测 pid 各不相同),`SetWindowLongPtr` 够不着,
|
|
89
|
+
* 所以「把 `HTTRANSPARENT` 一路传上去」这条子类化路线走不通。它们返回 `HTCLIENT`,
|
|
90
|
+
* 顶层窗口那套 `HTMAXBUTTON` 逻辑永远没机会被问到——**Snap Layouts 因此不弹**。
|
|
91
|
+
*
|
|
92
|
+
* 让 widget 别盖住标题栏,光标就落在顶层窗口自己身上,命中测试正常走到
|
|
93
|
+
* `hit_test_client` → `caption_button_at` → `HTMAXBUTTON`。
|
|
94
|
+
*
|
|
95
|
+
* ## 什么时候该给
|
|
96
|
+
*
|
|
97
|
+
* 只在**自绘标题栏**、而且标题栏真的在客户区顶部时。无边框不等于要自绘标题栏:
|
|
98
|
+
* 有的应用整窗都是内容区,无条件让位会在顶部留一条点不到的死区。
|
|
99
|
+
*
|
|
100
|
+
* ## 与 `FLAG_FRAMELESS` 的关系
|
|
101
|
+
*
|
|
102
|
+
* 本位只在 `FLAG_FRAMELESS` 同时给出时生效——带边框模式的标题栏由系统画,
|
|
103
|
+
* widget 本来就在客户区内,不存在遮挡。
|
|
104
|
+
*/
|
|
105
|
+
export const FLAG_TITLEBAR_RESERVE = 256;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 在客户区右上角盖一层**原生绘制**的 caption 按钮(关闭 / 最大化 / 最小化)。
|
|
109
|
+
*
|
|
110
|
+
* ## 它解决的是「页面拿不到 Snap Layouts」
|
|
111
|
+
*
|
|
112
|
+
* `WM_NCHITTEST` 走光标下最深那层,而 WebView2 的渲染子窗口在**别的进程**里、
|
|
113
|
+
* 返回 `HTCLIENT`,光标的查询根本到不了顶层窗口(`pitfalls` 2.14 的实测链)。
|
|
114
|
+
* 盖一个壳层自己的 layered 子窗口在按钮位置上,命中就落在壳层手里,
|
|
115
|
+
* `HTMAXBUTTON` 才生效,Snap 面板才会弹。
|
|
116
|
+
*
|
|
117
|
+
* ## 它与 `FLAG_TITLEBAR_RESERVE` 是**替代关系**,不要同时给
|
|
118
|
+
*
|
|
119
|
+
* `TITLEBAR_RESERVE` 的做法是把 WebView2 的容器子窗口整体下移 caption 高度,
|
|
120
|
+
* 让光标落到顶层窗口。那条路实测也能让 Snap 弹,但它把页面内容整体下移 32 逻辑
|
|
121
|
+
* 像素,而 caption 命中区固定在客户区 `y ∈ [0, 32)` —— 页面自己画的标题栏会与
|
|
122
|
+
* 命中区**错位 32px**,想对齐只能给页面负偏移,那已经出了 widget 客户区、
|
|
123
|
+
* 被子窗口裁掉。所以「HTML 画标题栏 + 让位」在几何上不成立。
|
|
124
|
+
*
|
|
125
|
+
* 本位不动布局:子窗口只是**盖在**按钮位置上,页面该怎么排还怎么排,
|
|
126
|
+
* 缩放边框也不受影响。
|
|
127
|
+
*
|
|
128
|
+
* ## 消费方要配合的一件事
|
|
129
|
+
*
|
|
130
|
+
* 用了本位就别再让页面画那三个按钮了,改由壳层画;页面的标题栏要在右侧留出
|
|
131
|
+
* `CAPTION_BUTTON_WIDTH * 3` 的空白(`mountTitleBar` 的 `captions: "native"`
|
|
132
|
+
* 就是做这件事的)。否则会看到两套按钮叠在一起。
|
|
133
|
+
*
|
|
134
|
+
* 只在 `FLAG_FRAMELESS` 同时给出时生效——带边框模式的标题栏由系统画。
|
|
135
|
+
*/
|
|
136
|
+
export const FLAG_NATIVE_CAPTIONS = 512;
|
|
75
137
|
|
|
76
138
|
/**
|
|
77
139
|
* 关闭 WebView2 的浏览器行为(`ShellOptions.browserUi` 使用)。
|
|
@@ -176,6 +238,37 @@ export const ZOOM_LADDER = [
|
|
|
176
238
|
export const ZOOM_MIN = ZOOM_LADDER[0];
|
|
177
239
|
export const ZOOM_MAX = ZOOM_LADDER[ZOOM_LADDER.length - 1];
|
|
178
240
|
|
|
241
|
+
/**
|
|
242
|
+
* 自绘标题栏的几何规格(**逻辑像素**,96 DPI 基准)。
|
|
243
|
+
*
|
|
244
|
+
* 这两个值有**三处**副本,`tests/types.test.ts` 用判据把它们钉在一起:
|
|
245
|
+
*
|
|
246
|
+
* | 处 | 用途 | 为什么不能省 |
|
|
247
|
+
* | --- | --- | --- |
|
|
248
|
+
* | 这里 | 页面侧布局与命中测试的共同事实源 | 页面要按它画,原生要按它算 |
|
|
249
|
+
* | `src/ui/titlebar.ts` 的 CSS | 视觉尺寸 | 页面真正渲染的是 CSS |
|
|
250
|
+
* | `native/shell.cc` 的 `kCaptionBarHeight` / `kCaptionButtonWidth` | `WM_NCHITTEST` 命中区 | C++ 无法 import TS |
|
|
251
|
+
*
|
|
252
|
+
* 为什么必须一致:`HTMAXBUTTON` 是「悬停弹 Snap Layouts」的**唯一**触发条件
|
|
253
|
+
* (微软文档明写 Win32 应用要返回它)。命中区与视觉按钮错位,表现为
|
|
254
|
+
* 「悬停位置偏了才弹」或「悬停不弹」——两种都极难从现象反推回常量。
|
|
255
|
+
*
|
|
256
|
+
* 规格取自 Win 11:标题栏 32 px、caption button 46×32。
|
|
257
|
+
*/
|
|
258
|
+
export const CAPTION_BAR_HEIGHT = 32;
|
|
259
|
+
export const CAPTION_BUTTON_WIDTH = 46;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 从右往左的三个 caption button,与 Win 11 的顺序一致。
|
|
263
|
+
*
|
|
264
|
+
* 顺序不是审美选择:`close` 贴右上角,`maximize` 在它左边,`minimize` 再左。
|
|
265
|
+
* `WM_NCHITTEST` 按这个顺序算矩形,页面 CSS 也按这个顺序排 DOM。
|
|
266
|
+
*/
|
|
267
|
+
export const CAPTION_BUTTONS = ["close", "maximize", "minimize"] as const;
|
|
268
|
+
|
|
269
|
+
/** `CAPTION_BUTTONS` 的成员名 */
|
|
270
|
+
export type CaptionButton = (typeof CAPTION_BUTTONS)[number];
|
|
271
|
+
|
|
179
272
|
/** window state 位掩码(与原生 shell_get_state 一致) */
|
|
180
273
|
export const STATE_MAXIMIZED = 1;
|
|
181
274
|
export const STATE_MINIMIZED = 2;
|
package/src/ui/titlebar.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
115
|
-
|
|
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.
|
|
137
|
-
--ws-tb-active: rgba(0, 0, 0, 0.
|
|
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-
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
},
|