diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs index a2224f4b..633a7583 100644 --- a/openless-all/app/scripts/windows-ui-config.test.mjs +++ b/openless-all/app/scripts/windows-ui-config.test.mjs @@ -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\);/, diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 6a69b47a..0a8484df 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -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", diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index b15723ea..03c69213 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -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(window: &tauri::WebviewWindow) { + 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 diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index f4d9df72..092267c6 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -21,7 +21,7 @@ "minWidth": 980, "minHeight": 640, "resizable": true, - "decorations": true, + "decorations": false, "transparent": true, "shadow": true, "hiddenTitle": true, diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 3f93aa6c..6e0de024 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -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', diff --git a/openless-all/app/src/components/WindowChrome.tsx b/openless-all/app/src/components/WindowChrome.tsx index a572c5dc..c460b260 100644 --- a/openless-all/app/src/components/WindowChrome.tsx +++ b/openless-all/app/src/components/WindowChrome.tsx @@ -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 (
{os === 'win' && } - {/* macOS:三色窗口按钮交给系统绘制和定位。这里只保留顶部拖动区, - 并避开系统按钮热区,防止拖动层吞掉 close/minimize/zoom 点击。 */} + {os === 'win' && } {os === 'mac' && (
{title}
-
- - - +
); } +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 ( +
+ {handles.map(handle => ( +
{ + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + void startResizeDragging(handle.direction); + }} + style={{ + position: 'absolute', + pointerEvents: 'auto', + cursor: handle.cursor, + ...handle.style, + }} + /> + ))} +
+ ); +} + +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 ( + + ); +} + +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)', -}; diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index 8ab6a8d5..cda62844 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -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 {