Merge pull request #181 from Cooper-X-Oak/codex/windows-native-titlebar-off

fix(windows): 收紧主窗口 shell 外观契约
This commit is contained in:
TRIP authored and GitHub committed 2026-05-02 12:18:12 +08:00
commit 7aefb8199e
7 files changed
+265 -43

No files matched your search

@@ -15,18 +15,39 @@ function assertMatch(source, pattern, name) {
const raw = await readFile(new URL('../src-tauri/tauri.conf.json', import.meta.url), 'utf-8');
const config = JSON.parse(raw);
const capsuleWindow = config.app.windows.find((window) => window.label === 'capsule');
const mainWindow = config.app.windows.find((window) => window.label === 'main');
const libRs = await readFile(new URL('../src-tauri/src/lib.rs', import.meta.url), 'utf-8');
const coordinatorRs = await readFile(new URL('../src-tauri/src/coordinator.rs', import.meta.url), 'utf-8');
const capsuleTsx = await readFile(new URL('../src/components/Capsule.tsx', import.meta.url), 'utf-8');
const capsuleLayoutTs = await readFile(new URL('../src/lib/capsuleLayout.ts', import.meta.url), 'utf-8');
const windowChromeTsx = await readFile(new URL('../src/components/WindowChrome.tsx', import.meta.url), 'utf-8');
const floatingShellTsx = await readFile(new URL('../src/components/FloatingShell.tsx', import.meta.url), 'utf-8');
if (!capsuleWindow) {
throw new Error('capsule window config missing');
}
if (!mainWindow) {
throw new Error('main window config missing');
}
assertEqual(capsuleWindow.width, 220, 'windows capsule config keeps translation-capable width baseline');
assertEqual(capsuleWindow.height, 110, 'windows capsule config keeps translation-capable height baseline');
assertEqual(capsuleWindow.transparent, true, 'capsule window should keep transparent visuals');
assertEqual(capsuleWindow.alwaysOnTop, true, 'capsule window should stay above the focused app while recording');
assertEqual(mainWindow.decorations, false, 'windows main window should use only custom titlebar');
assertEqual(mainWindow.visible, false, 'windows main window should stay hidden until the intended first show point');
if (!/function WindowsResizeHandles\(\)/.test(windowChromeTsx)) {
throw new Error('windows frameless shell should expose explicit resize handles');
}
if (!/startResizeDragging\(direction\)/.test(windowChromeTsx)) {
throw new Error('windows resize handles should delegate edge dragging to Tauri');
}
if (!/borderRadius:\s*'var\(--ol-window-console-radius\)'/.test(floatingShellTsx)) {
throw new Error('floating shell should consume the shared window-console radius');
}
assertMatch(
coordinatorRs,
/let visible = !matches!\(state,\s*CapsuleState::Idle\);/,
+3
View File
@@ -53,8 +53,11 @@ objc2-foundation = "0.2"
objc2-app-kit = "0.2"
[target.'cfg(target_os = "windows")'.dependencies]
raw-window-handle = "0.6"
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Graphics_Dwm",
"Win32_Graphics_Gdi",
"Win32_UI_Shell",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
+107 -4
View File
@@ -105,12 +105,18 @@ pub fn run() {
#[cfg(target_os = "windows")]
{
use window_vibrancy::apply_mica;
if let Err(e) = apply_mica(&main, None) {
log::warn!("[main] mica failed: {e}");
}
// The window starts hidden so Windows native chrome can be disabled before
// the first show; doing this after the native frame is visible is unreliable.
if let Err(e) = main.set_decorations(false) {
log::warn!("[main] disable native decorations failed: {e}");
}
if let Err(e) = apply_mica(&main, None) {
log::warn!("[main] mica failed: {e}");
}
apply_windows_rounded_frame(&main);
}
if let Err(e) = main.show() {
log::warn!("[main] initial show failed: {e}");
}
}
@@ -214,10 +220,16 @@ pub fn run() {
RunEvent::Reopen { .. } => show_main_window(app),
RunEvent::WindowEvent { label, event, .. } => {
if label == "main" {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if let tauri::WindowEvent::CloseRequested { ref api, .. } = event {
api.prevent_close();
hide_main_window(app);
}
#[cfg(target_os = "windows")]
if matches!(event, tauri::WindowEvent::Resized(_) | tauri::WindowEvent::ScaleFactorChanged { .. }) {
if let Some(main) = app.get_webview_window("main") {
apply_windows_rounded_frame(&main);
}
}
}
}
RunEvent::Exit => {
@@ -229,6 +241,97 @@ pub fn run() {
});
}
#[cfg(target_os = "windows")]
fn apply_windows_rounded_frame<R: Runtime>(window: &tauri::WebviewWindow<R>) {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use windows::Win32::Foundation::{BOOL, HWND, RECT};
use windows::Win32::Graphics::Dwm::{
DwmSetWindowAttribute, DWMWA_BORDER_COLOR, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND,
};
use windows::Win32::Graphics::Gdi::{CreateRoundRectRgn, SetWindowRgn, HRGN};
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowLongW, GetWindowRect, SetWindowLongW, SetWindowPos, GWL_STYLE, SWP_FRAMECHANGED,
SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, WS_CAPTION, WS_THICKFRAME,
};
let handle = match window.window_handle().map(|h| h.as_raw()) {
Ok(RawWindowHandle::Win32(handle)) => handle,
Ok(other) => {
log::warn!("[main] unexpected raw window handle for DWM frame: {other:?}");
return;
}
Err(e) => {
log::warn!("[main] read raw window handle failed: {e}");
return;
}
};
let hwnd = HWND(handle.hwnd.get() as *mut core::ffi::c_void);
unsafe {
let style = GetWindowLongW(hwnd, GWL_STYLE);
let desired_style = (style | WS_THICKFRAME.0 as i32) & !(WS_CAPTION.0 as i32);
if style != desired_style {
SetWindowLongW(hwnd, GWL_STYLE, desired_style);
if let Err(e) = SetWindowPos(
hwnd,
HWND::default(),
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED,
) {
log::warn!("[main] refresh native frame after style update failed: {e}");
}
}
if window.is_maximized().unwrap_or(false) {
let _ = SetWindowRgn(hwnd, HRGN::default(), BOOL(1));
return;
}
let corner_preference = DWMWCP_ROUND;
if let Err(e) = DwmSetWindowAttribute(
hwnd,
DWMWA_WINDOW_CORNER_PREFERENCE,
&corner_preference as *const _ as *const core::ffi::c_void,
std::mem::size_of_val(&corner_preference) as u32,
) {
log::warn!("[main] set DWM rounded corners failed: {e}");
}
// Remove DWM's fallback 1px light border; the React shell draws the visual stroke.
let border_color_none: u32 = 0xFFFFFFFE;
if let Err(e) = DwmSetWindowAttribute(
hwnd,
DWMWA_BORDER_COLOR,
&border_color_none as *const _ as *const core::ffi::c_void,
std::mem::size_of_val(&border_color_none) as u32,
) {
log::warn!("[main] remove DWM border color failed: {e}");
}
let mut rect = RECT::default();
if let Err(e) = GetWindowRect(hwnd, &mut rect) {
log::warn!("[main] read window rect for rounded region failed: {e}");
return;
}
let width = rect.right - rect.left;
let height = rect.bottom - rect.top;
if width <= 0 || height <= 0 {
return;
}
let region = CreateRoundRectRgn(0, 0, width + 1, height + 1, 18, 18);
if region.is_invalid() {
log::warn!("[main] create rounded window region failed");
return;
}
if SetWindowRgn(hwnd, region, BOOL(1)) == 0 {
log::warn!("[main] apply rounded window region failed");
}
}
}
#[tauri::command]
fn restart_app(app: AppHandle) {
// macOS:自动更新会让新装的 .app 带 com.apple.quarantine(无论 Tauri updater
+1 -1
View File
@@ -21,7 +21,7 @@
"minWidth": 980,
"minHeight": 640,
"resizable": true,
"decorations": true,
"decorations": false,
"transparent": true,
"shadow": true,
"hiddenTitle": true,
@@ -255,7 +255,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
flex: 1, minWidth: 0,
overflow: 'hidden',
background: 'var(--ol-surface)',
borderRadius: os === 'mac' ? 20 : 14,
borderRadius: 'var(--ol-window-console-radius)',
border: '0.5px solid rgba(0,0,0,0.06)',
boxShadow: '0 1px 0 rgba(255,255,255,0.8) inset, 0 8px 24px -12px rgba(15,17,22,0.10), 0 2px 6px -2px rgba(15,17,22,0.06)',
display: 'flex',
+129 -36
View File
@@ -1,18 +1,6 @@
// WindowChrome.tsx — frosted outer frame + raised inner console pattern.
// The OUTER frame is a translucent shell with a tinted backdrop showing through.
// The INNER content lives in a single raised card that floats above it.
//
// Layout per window:
// ┌─ frosted outer ───────────────────────────────┐
// │ [titlebar] │
// │ ┌─ raised console (white, shadow) ─┐ │
// │ │ sidebar │ main │ │
// │ └──────────────────────────────────┘ │
// │ [icon footer] │
// └───────────────────────────────────────────────┘
import { type CSSProperties, type ReactNode } from 'react';
import { useState, type CSSProperties, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { getCurrentWindow } from '@tauri-apps/api/window';
export type OS = 'mac' | 'win' | 'linux';
@@ -30,6 +18,21 @@ export function detectOS(): OS {
const MAC_TITLEBAR_HEIGHT = 36;
const MAC_SYSTEM_CONTROLS_RESERVED_WIDTH = 80;
export const WIN_TITLEBAR_HEIGHT = 36;
export const WIN_WINDOW_RADIUS = 10;
export const WIN_CONSOLE_RADIUS = 10;
const WIN_RESIZE_EDGE = 6;
const WIN_RESIZE_CORNER = 14;
type ResizeDirection =
| 'East'
| 'North'
| 'NorthEast'
| 'NorthWest'
| 'South'
| 'SouthEast'
| 'SouthWest'
| 'West';
interface WindowChromeProps {
os?: OS;
@@ -38,19 +41,32 @@ interface WindowChromeProps {
height?: number | string;
}
export function WindowChrome({ os = 'mac', title = 'OpenLess', children, height = 800 }: WindowChromeProps) {
export function WindowChrome({
os = 'mac',
title = 'OpenLess',
children,
height = 800,
}: WindowChromeProps) {
const shellRadius = os === 'mac' ? 20 : os === 'win' ? WIN_WINDOW_RADIUS : 14;
const consoleRadius = os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : 14;
return (
<div
style={{
'--ol-window-shell-radius': `${shellRadius}px`,
'--ol-window-console-radius': `${consoleRadius}px`,
'--ol-window-titlebar-height': `${os === 'mac' ? MAC_TITLEBAR_HEIGHT : WIN_TITLEBAR_HEIGHT}px`,
width: '100%',
height,
position: 'relative',
borderRadius: os === 'mac' ? 20 : 14,
boxShadow: 'var(--ol-shadow-xl)',
borderRadius: 'var(--ol-window-shell-radius)',
boxShadow: os === 'win'
? '0 22px 54px -28px rgba(15, 17, 22, 0.38), 0 10px 30px -20px rgba(15, 17, 22, 0.22), 0 0 0 0.5px rgba(0, 0, 0, 0.10)'
: 'var(--ol-shadow-xl)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
border: '0.5px solid rgba(0,0,0,.10)',
border: os === 'win' ? '1px solid rgba(255,255,255,0.18)' : '0.5px solid rgba(0,0,0,.10)',
background: `
radial-gradient(120% 80% at 0% 0%, rgba(255,255,255,0.7) 0%, rgba(255,255,255,0) 60%),
radial-gradient(100% 70% at 100% 100%, rgba(37,99,235,0.07) 0%, rgba(37,99,235,0) 55%),
@@ -58,11 +74,10 @@ export function WindowChrome({ os = 'mac', title = 'OpenLess', children, height
`,
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
}}
} as CSSProperties}
>
{os === 'win' && <WinTitleBar title={title} />}
{/* macOS:三色窗口按钮交给系统绘制和定位。这里只保留顶部拖动区,
并避开系统按钮热区,防止拖动层吞掉 close/minimize/zoom 点击。 */}
{os === 'win' && <WindowsResizeHandles />}
{os === 'mac' && (
<div
data-tauri-drag-region
@@ -92,12 +107,14 @@ function WinTitleBar({ title }: WinTitleBarProps) {
return (
<div
style={{
height: 36,
height: WIN_TITLEBAR_HEIGHT,
flexShrink: 0,
display: 'flex',
alignItems: 'stretch',
position: 'relative',
zIndex: 5,
zIndex: 70,
borderBottom: '0.5px solid rgba(0, 0, 0, 0.08)',
background: 'linear-gradient(180deg, rgba(255,255,255,0.20) 0%, rgba(255,255,255,0.08) 100%)',
}}
>
<div
@@ -107,24 +124,105 @@ function WinTitleBar({ title }: WinTitleBarProps) {
<img src="AppIcon.png" alt="" style={{ width: 14, height: 14, borderRadius: 3 }} />
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)', fontWeight: 500 }}>{title}</span>
</div>
<div style={{ display: 'flex' }}>
<button style={winBtnStyle} title={t('windowChrome.minimize')} onClick={() => runWindowsWindowAction('minimize')}>
<div style={{ display: 'flex', pointerEvents: 'auto' }}>
<WinTitleButton title={t('windowChrome.minimize')} action="minimize">
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M0 5h10" stroke="currentColor" strokeWidth="1" /></svg>
</button>
<button style={winBtnStyle} title={t('windowChrome.maximize')} onClick={() => runWindowsWindowAction('toggleMaximize')}>
</WinTitleButton>
<WinTitleButton title={t('windowChrome.maximize')} action="toggleMaximize">
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.5" y="0.5" width="9" height="9" stroke="currentColor" strokeWidth="1" fill="none" /></svg>
</button>
<button style={winCloseBtnStyle} title={t('windowChrome.close')} onClick={() => runWindowsWindowAction('close')}>
</WinTitleButton>
<WinTitleButton title={t('windowChrome.close')} action="close" tone="danger">
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M0 0L10 10M10 0L0 10" stroke="currentColor" strokeWidth="1" /></svg>
</button>
</WinTitleButton>
</div>
</div>
);
}
function WindowsResizeHandles() {
const handles: Array<{
direction: ResizeDirection;
cursor: CSSProperties['cursor'];
style: CSSProperties;
}> = [
{ direction: 'North', cursor: 'ns-resize', style: { top: 0, left: WIN_RESIZE_CORNER, right: WIN_RESIZE_CORNER, height: WIN_RESIZE_EDGE } },
{ direction: 'South', cursor: 'ns-resize', style: { bottom: 0, left: WIN_RESIZE_CORNER, right: WIN_RESIZE_CORNER, height: WIN_RESIZE_EDGE } },
{ direction: 'West', cursor: 'ew-resize', style: { top: WIN_RESIZE_CORNER, bottom: WIN_RESIZE_CORNER, left: 0, width: WIN_RESIZE_EDGE } },
{ direction: 'East', cursor: 'ew-resize', style: { top: WIN_RESIZE_CORNER, bottom: WIN_RESIZE_CORNER, right: 0, width: WIN_RESIZE_EDGE } },
{ direction: 'NorthWest', cursor: 'nwse-resize', style: { top: 0, left: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } },
{ direction: 'NorthEast', cursor: 'nesw-resize', style: { top: 0, right: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } },
{ direction: 'SouthWest', cursor: 'nesw-resize', style: { bottom: 0, left: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } },
{ direction: 'SouthEast', cursor: 'nwse-resize', style: { bottom: 0, right: 0, width: WIN_RESIZE_CORNER, height: WIN_RESIZE_CORNER } },
];
return (
<div aria-hidden style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 60 }}>
{handles.map(handle => (
<div
key={handle.direction}
onMouseDown={event => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
void startResizeDragging(handle.direction);
}}
style={{
position: 'absolute',
pointerEvents: 'auto',
cursor: handle.cursor,
...handle.style,
}}
/>
))}
</div>
);
}
interface WinTitleButtonProps {
title: string;
action: 'minimize' | 'toggleMaximize' | 'close';
tone?: 'default' | 'danger';
children: ReactNode;
}
function WinTitleButton({ title, action, tone = 'default', children }: WinTitleButtonProps) {
const [hovered, setHovered] = useState(false);
const [pressed, setPressed] = useState(false);
const danger = tone === 'danger';
const background = pressed
? danger ? '#c42b1c' : 'rgba(0, 0, 0, 0.12)'
: hovered ? danger ? '#e81123' : 'rgba(0, 0, 0, 0.08)'
: 'transparent';
const color = danger && (hovered || pressed) ? '#fff' : 'var(--ol-ink-3)';
return (
<button
style={{ ...winBtnStyle, background, color }}
title={title}
onClick={() => runWindowsWindowAction(action)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => {
setHovered(false);
setPressed(false);
}}
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
>
{children}
</button>
);
}
async function startResizeDragging(direction: ResizeDirection) {
try {
await getCurrentWindow().startResizeDragging(direction);
} catch (error) {
console.warn(`[window] Windows resize ${direction} failed`, error);
}
}
async function runWindowsWindowAction(action: 'minimize' | 'toggleMaximize' | 'close') {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
if (action === 'minimize') {
await currentWindow.minimize();
@@ -150,8 +248,3 @@ const winBtnStyle: CSSProperties = {
cursor: 'default',
transition: 'background 0.12s ease-out, color 0.12s ease-out',
};
const winCloseBtnStyle: CSSProperties = {
...winBtnStyle,
color: 'var(--ol-ink-3)',
};
+3 -1
View File
@@ -3,17 +3,19 @@ html, body, #root {
width: 100%;
margin: 0;
padding: 0;
background: transparent;
overflow: hidden;
}
body {
background: transparent;
user-select: none;
-webkit-user-select: none;
overflow: hidden;
}
#root {
display: flex;
isolation: isolate;
}
button {