Skip to content

Commit

Permalink
Feat: Support Windows Transparent Title Bar (#1578)
Browse files Browse the repository at this point in the history
* feat: support windows transparent title bar.

1. Add windows transparent title bar.
2. Move macOS special window adaption to MacOSWindowFrame.kt.
3. Refactor WindowsWindowUtils.kt partially.

* Fix: Cache the HitTestResult constructor

Cache the `HitTestResult` constructor to avoid reflection overhead on every hit test.

* update nc_cal_size behavior

* Update some comments.

* Fix: Remove some unnecessary reflection invoke.

* Remove println in LayoutHitTestOwner.kt

* Remove LaunchedEffect and directly update title bar theme

The `LaunchedEffect` used to update the title bar theme based on dark mode settings and system theme has been removed. The title bar theme is now updated directly, eliminating the need for a coroutine scope.

* Refactor: Simplify window state flows in WindowsWindowUtils

Simplify the `windowIsActive` and `windowAccentColor` flows in `WindowsWindowUtils` by using `flowOf` instead of `flow` for emitting null or default values. This makes the code more concise and readable.

* Refactor: Use `isTopRight()` function for window insets

This commit refactors the code to use the `isTopRight()` function for checking if window insets are on the top right.

This simplifies the logic and makes the code more readable.

* Fix: Handle null titleBarController in DarkCaptionButtonAppearance

The change handles the case when titleBarController is null in DarkCaptionButtonAppearance by returning early.

* Add comments to clarify usage of TitleBarThemeController

* Refactor: Update desktop hit test to use _layersCopyCache

Update the desktop hit test implementation to use the `_layersCopyCache` field instead of the `layers` field. This ensures that the hit test is performed on the correct set of layers and improves performance.

* Fix: Avoid title bar buttons hit test interference

This change avoids title bar buttons hit test interference by excluding AbstractClickableNodes and HoverableNodes from hit test results. This ensures that pointer input for Material 3 components is detected correctly.

* Fix: Handle null layoutHitTestOwner in WindowsWindowFrame

The change handles the case when layoutHitTestOwner is null in WindowsWindowFrame by returning early and calling the content function.

* Refactor: Remove window hit test constants in WindowsWindowUtils.

This change improves the organization of the code and makes it more maintainable.

* Refactor: Update TitleBarWindowProc post ncbutton message logic

the `SkiaLayerHitTestWindowProc` class is now internal and its `contentHandle` property is exposed for access.

* Refactor: Move windowsBuildNumber() to WindowsWindowUtils

Move the `windowsBuildNumber()` function to `WindowsWindowUtils` to avoid redundant code and improve maintainability.
Also draw a fake top border when window is inactive on Windows version less than 22000 (Windows 11).

* Mark some raw platform methods in WindowUtils.

* Apply window title property only when dependencies changes.

* Fix: Draw fake top border when window is inactive on Windows version less than 22000 (Windows 11)

This commit fixes the issue where the top border of the window is not drawn correctly when the window is inactive on Windows versions less than 22000 (Windows 11).
It achieves this by drawing a fake top border using `drawLine` when the window is inactive and the Windows version is less than 22000.
The color of the fake border is determined based on whether the window frame is colorful and the window's active state.
If the window frame is colorful and the window is active, the accent color is used.
If the window frame is not colorful or the window is inactive, a default color is used.

* Fix: Correct top padding calculation for maximized windows on Windows

The top padding calculation for maximized windows on Windows has been corrected. Previously, it was not taking into account the frameX value, which could result in incorrect padding.

This change ensures that the top padding is calculated correctly, even when the window is maximized.

* Refactor: Update `hitTest` logic in `WindowsWindowUtils`

The `hitTest` logic in `WindowsWindowUtils` has been updated to improve window resizing behavior.
- Add `hitTestWindowResizerBorder` method to determine if the cursor is within the window resizer border.
- Call `hitTestWindowResizerBorder` before `childHitTest` and only call it if the window is not maximized.
- Update `WM_NCHITTEST` message logic.
- Return `HTNOWHERE` when `hitTestWindowResizerBorder` is not triggered.
- `hitTestResult` will use the result of `callResult.toInt()` from `CallWindowProc` in some cases.

* Refactor: Move window hit test constants to WindowsWindowUtils

This commit moves the window hit test constants (such as `HTCLIENT`, `HTMAXBUTTON`, etc.) to `WindowsWindowUtils` for better organization and accessibility. This change also update the usages of these constants in `WindowsWindowFrame` to use the new locations.

* Refactor: Improve Windows title bar and fix maximized window padding

- Refactor `WindowsWindowUtils` to calculate and apply correct padding for maximized windows.
- Add `NCCalcSizeParams` to `TitleBarWindowProc` to handle window frame and padding calculations.
- Workaround background erase.
- Hide and show window in init to solve background erase.
- Remove top border fix workaround in `WindowsWindowFrame`.
- Update `isWindowInMaximized()` function to `WindowsWindowUtils`.
- Update title bar logic in `TitleBarWindowProc` to support maximized windows.
- The behavior of full screen is now call `WM_NCCALCSIZE` in `TitleBarWindowProc`.
- Remove the wrong window inset padding.

* Refactor: Use system accent color for title bar caption buttons on Windows

This commit refactors the title bar caption buttons on Windows to use the system accent color when available.
If the system's accent color is enabled, the title bar buttons will use the accent color as their background.
Otherwise, they will use the default colors.

Also use `shellCloseColor` for Close button when the accent color is available.

* Refactor: Update `LayoutHitTestOwner` to use `ComposeSceneContext`

- Update `rememberLayoutHitTestOwner` to use `LocalComposeSceneContext` instead of `LocalComposeScene`.
- Change `scene::class.qualifiedName` to `scene::class.java.canonicalName` for class name comparison.

* Refactor: Refactor `WindowsWindowFrame` and add `WindowsWindowFrameState`

- Introduce `WindowsWindowFrameState` to manage window frame state, such as title bar visibility, caption button rects, and window insets.
- Move the logic of extending the window content to the title bar into `ExtendToTitleBar`.
- Move caption button colors logic to `WindowsWindowFrameState.collectCaptionButtonColors`.
- Add `rememberFontIconFamily` function to get the windows system font icon.
- Update `hitTest` logic to be within `WindowsWindowFrameState`.
- Call `collectWindowIsActive()` in `WindowsWindowFrameState`.
- Move `windowIsActive` flow collect logic to `WindowsWindowFrameState`.
- Remove the dependency between `layoutHitTestOwner` and `WindowsWindowFrame`.
- Remove redundant `captionButtonThemeController` and `isTitleBarVisible`.
- Update the visibility of `WindowsWindowFrame`.
- Update the title bar visibility logic in `WindowsWindowFrame`.
- Hide title bar if window is full screen mode and title bar is not hovered.
- Update `CaptionButtonRow` parameters to use `frameState` instead of `isActive` and `layoutHitTestOwner`.

* Refactor: Update title bar theme logic with stack-based approach

-   Introduce a `TitleBarThemeController` to manage the title bar's dark mode theme.
-   Implement a stack-based approach to manage the title bar theme requests, allowing multiple components to request changes.
-   Update `DarkCaptionButtonAppearance` to request a dark theme when active and remove the request on dispose.
-   Add tests for `DarkCaptionButtonAppearance` to verify theme behavior.
-   Update `AniDesktop` to use the `TitleBarThemeController` to set the window title bar theme.
-   Remove direct theme setting from `TitleBarThemeController`.

* Update comment

* Refactor: Rename DarkCaptionButtonAppearance to OverrideCaptionButtonAppearance

This commit renames `DarkCaptionButtonAppearance` to `OverrideCaptionButtonAppearance` for better clarity and to support overriding the default caption button appearance based on the application theme.

- Rename `DarkCaptionButtonAppearance` to `OverrideCaptionButtonAppearance` in common, desktop, ios, and android modules.
- Add `isDark` parameter to `OverrideCaptionButtonAppearance` to allow the app to override caption buttons based on light/dark mode.
- Update `OverrideCaptionButtonAppearance` behavior: caption buttons will use the theme requested by the caller of `OverrideCaptionButtonAppearance` if `isDark` is true.
- Update `EpisodePage` and `AniDesktop` to use `OverrideCaptionButtonAppearance` and set `isDark` as needed.
- Update `OverrideCaptionButtonAppearanceTest` to reflect the changes.

* Refactor: Improve Desktop Title Bar Integration

- Add `WindowInsets.desktopTitleBar` to provide proper window insets for the desktop title bar.
- Add `consumeWindowInsets` in `VideoScaffold` for better integration with the desktop title bar.
- Add `windowInsets` parameters in `EpisodePage` to pass windowInsets to `VideoScaffold`.
- Add `pointerInput` Modifier for title bar in `VideoScaffold` to skip layout hit test.
- Use `Spacer` to add the title bar padding in `VideoScaffold` instead of using `contentWindowInsets`.

* update comments

* remove WindowsWindowUtils from CaptionButtonRow.

---------

Co-authored-by: StageGuard <[email protected]>
  • Loading branch information
Sanlorng and StageGuard authored Feb 10, 2025
1 parent 210c324 commit fd0ad8a
Show file tree
Hide file tree
Showing 24 changed files with 2,109 additions and 80 deletions.
25 changes: 25 additions & 0 deletions app/desktop/proguard-desktop.pro
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,29 @@
-keep class com.jthemedetecor.** { *; } #1404 OsThemeDetector
-keep class oshi.** { *; } #1404 OsThemeDetector

# LayoutHitTestOwner ProGuard rules
# 保持被反射调用的类
-keep class androidx.compose.foundation.HoverableNode { *; }
-keep class androidx.compose.foundation.gestures.ScrollableNode { *; }

-keep class androidx.compose.ui.scene.PlatformLayersComposeSceneImpl { *; }
-keep class androidx.compose.ui.scene.CanvasLayersComposeSceneImpl { *; }
-keep class androidx.compose.ui.scene.CanvasLayersComposeSceneImpl$AttachedComposeSceneLayer { *; }

# 保持被反射访问的字段和方法
-keepclassmembers class androidx.compose.ui.scene.PlatformLayersComposeSceneImpl {
private *** getMainOwner(); # 反射调用的方法
}

-keepclassmembers class androidx.compose.ui.scene.CanvasLayersComposeSceneImpl {
private *** mainOwner; # 反射访问的字段
private *** _layersCopyCache;
private *** focusedLayer;
}

-keepclassmembers class androidx.compose.ui.scene.CanvasLayersComposeSceneImpl$AttachedComposeSceneLayer {
private *** owner; # 反射访问的字段
private *** isInBounds(...); # 反射调用的方法
}

-verbose
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 30 additions & 32 deletions app/desktop/src/main/kotlin/AniDesktop.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
package me.him188.ani.app.desktop

import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
Expand All @@ -26,12 +25,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.LocalSystemTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.SystemTheme
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
Expand All @@ -44,13 +43,15 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.io.files.Path
import me.him188.ani.app.data.models.preference.DarkMode
import me.him188.ani.app.data.persistent.dataStores
import me.him188.ani.app.data.repository.SavedWindowState
import me.him188.ani.app.data.repository.WindowStateRepository
import me.him188.ani.app.data.repository.WindowStateRepositoryImpl
import me.him188.ani.app.data.repository.user.SettingsRepository
import me.him188.ani.app.desktop.storage.AppFolderResolver
import me.him188.ani.app.desktop.storage.AppInfo
import me.him188.ani.app.desktop.window.WindowFrame
import me.him188.ani.app.domain.foundation.HttpClientProvider
import me.him188.ani.app.domain.foundation.ScopedHttpClientUserAgent
import me.him188.ani.app.domain.foundation.get
Expand Down Expand Up @@ -87,18 +88,20 @@ import me.him188.ani.app.platform.getCommonKoinModule
import me.him188.ani.app.platform.notification.NoopNotifManager
import me.him188.ani.app.platform.notification.NotifManager
import me.him188.ani.app.platform.startCommonKoinModule
import me.him188.ani.app.platform.window.LocalTitleBarThemeController
import me.him188.ani.app.platform.window.setTitleBar
import me.him188.ani.app.tools.update.DesktopUpdateInstaller
import me.him188.ani.app.tools.update.UpdateInstaller
import me.him188.ani.app.torrent.anitorrent.AnitorrentLibraryLoader
import me.him188.ani.app.ui.foundation.LocalPlatform
import me.him188.ani.app.ui.foundation.LocalWindowState
import me.him188.ani.app.ui.foundation.effects.OverrideCaptionButtonAppearance
import me.him188.ani.app.ui.foundation.ifThen
import me.him188.ani.app.ui.foundation.layout.LocalPlatformWindow
import me.him188.ani.app.ui.foundation.layout.isSystemInFullscreen
import me.him188.ani.app.ui.foundation.navigation.LocalOnBackPressedDispatcherOwner
import me.him188.ani.app.ui.foundation.navigation.SkikoOnBackPressedDispatcherOwner
import me.him188.ani.app.ui.foundation.theme.AniThemeDefaults
import me.him188.ani.app.ui.foundation.theme.LocalThemeSettings
import me.him188.ani.app.ui.foundation.widgets.LocalToaster
import me.him188.ani.app.ui.foundation.widgets.Toast
import me.him188.ani.app.ui.foundation.widgets.ToastViewModel
Expand All @@ -112,8 +115,6 @@ import me.him188.ani.utils.io.toKtPath
import me.him188.ani.utils.logging.error
import me.him188.ani.utils.logging.info
import me.him188.ani.utils.logging.logger
import me.him188.ani.utils.platform.currentPlatformDesktop
import me.him188.ani.utils.platform.isMacOS
import org.jetbrains.compose.resources.painterResource
import org.koin.core.context.startKoin
import org.koin.dsl.module
Expand Down Expand Up @@ -408,35 +409,14 @@ object AniDesktop {
@OptIn(InternalComposeUiApi::class)
LocalSystemTheme provides systemTheme,
) {
// This actually runs only once since app is never changed.
val windowImmersed = true

SideEffect {
// https://www.formdev.com/flatlaf/macos/
if (currentPlatformDesktop().isMacOS()) {
window.rootPane.putClientProperty("apple.awt.application.appearance", "system")
window.rootPane.putClientProperty("apple.awt.fullscreenable", true)
if (windowImmersed) {
window.rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
window.rootPane.putClientProperty("apple.awt.fullWindowContent", true)
window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
} else {
window.rootPane.putClientProperty("apple.awt.fullWindowContent", false)
window.rootPane.putClientProperty("apple.awt.transparentTitleBar", false)
}
}
}

if (LocalPlatform.current.isMacOS()) {
// CMP bug, 退出全屏后窗口会变为 Maximized, 而不是还原到 Floating
if (!isSystemInFullscreen() && windowState.placement == WindowPlacement.Maximized) {
SideEffect {
windowState.placement = WindowPlacement.Floating
}
}
WindowFrame(
windowState = windowState,
onCloseRequest = { exitApplication() },
) {
MainWindowContent(navigator)
}

MainWindowContent(navigator)
}
}

Expand All @@ -445,13 +425,31 @@ object AniDesktop {
}
}

@OptIn(InternalComposeUiApi::class)
@Composable
private fun FrameWindowScope.MainWindowContent(
aniNavigator: AniNavigator,
) {
val settingsRepository = KoinPlatform.getKoin().get<SettingsRepository>()
AniApp {
window.setTitleBar(AniThemeDefaults.navigationContainerColor, isSystemInDarkTheme())
val themeSettings = LocalThemeSettings.current
val titleBarThemeController = LocalTitleBarThemeController.current
val systemTheme = LocalSystemTheme.current
val navContainerColor = AniThemeDefaults.navigationContainerColor

val isTitleBarDark = remember(themeSettings, systemTheme) {
when (themeSettings.darkMode) {
DarkMode.AUTO -> systemTheme == SystemTheme.Dark
DarkMode.LIGHT -> false
DarkMode.DARK -> true
}
}
DisposableEffect(isTitleBarDark, titleBarThemeController) {
window.setTitleBar(navContainerColor, isTitleBarDark)
onDispose {}
}

OverrideCaptionButtonAppearance(isDark = isTitleBarDark)

Box(
Modifier
Expand Down
70 changes: 70 additions & 0 deletions app/desktop/src/main/kotlin/window/MacOSWindowFrame.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright (C) 2024-2025 OpenAni and contributors.
*
* 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
* Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link.
*
* https://github.com/open-ani/ani/blob/main/LICENSE
*/

package me.him188.ani.app.desktop.window

import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowState
import me.him188.ani.app.ui.foundation.LocalPlatform
import me.him188.ani.app.ui.foundation.layout.LocalCaptionButtonInsets
import me.him188.ani.app.ui.foundation.layout.LocalTitleBarInsets
import me.him188.ani.app.ui.foundation.layout.ZeroInsets
import me.him188.ani.app.ui.foundation.layout.isSystemInFullscreen
import me.him188.ani.utils.platform.currentPlatformDesktop
import me.him188.ani.utils.platform.isMacOS

@Composable
fun FrameWindowScope.MacOSWindowFrame(windowState: WindowState, content: @Composable () -> Unit) {
// This actually runs only once since app is never changed.
val windowImmersed = true

SideEffect {
// https://www.formdev.com/flatlaf/macos/
if (currentPlatformDesktop().isMacOS()) {
window.rootPane.putClientProperty("apple.awt.application.appearance", "system")
window.rootPane.putClientProperty("apple.awt.fullscreenable", true)
if (windowImmersed) {
window.rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
window.rootPane.putClientProperty("apple.awt.fullWindowContent", true)
window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
} else {
window.rootPane.putClientProperty("apple.awt.fullWindowContent", false)
window.rootPane.putClientProperty("apple.awt.transparentTitleBar", false)
}
}
}
if (LocalPlatform.current.isMacOS()) {
// CMP bug, 退出全屏后窗口会变为 Maximized, 而不是还原到 Floating
if (!isSystemInFullscreen() && windowState.placement == WindowPlacement.Maximized) {
SideEffect {
windowState.placement = WindowPlacement.Floating
}
}
}

CompositionLocalProvider(
LocalTitleBarInsets provides if (!isSystemInFullscreen()) {
WindowInsets(top = 28.dp) // 实际上是 22, 但是为了美观, 加大到 28
} else {
ZeroInsets
},
LocalCaptionButtonInsets provides if (!isSystemInFullscreen()) {
WindowInsets(left = 80.dp, top = 28.dp)
} else {
ZeroInsets
},
content = content,
)
}
41 changes: 41 additions & 0 deletions app/desktop/src/main/kotlin/window/WindowFrame.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright (C) 2024-2025 OpenAni and contributors.
*
* 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
* Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link.
*
* https://github.com/open-ani/ani/blob/main/LICENSE
*/

package me.him188.ani.app.desktop.window

import androidx.compose.runtime.Composable
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.WindowState
import me.him188.ani.utils.platform.Platform
import me.him188.ani.utils.platform.currentPlatformDesktop

@Composable
fun FrameWindowScope.WindowFrame(
windowState: WindowState,
onCloseRequest: () -> Unit,
content: @Composable () -> Unit
) {
when (currentPlatformDesktop()) {
is Platform.MacOS -> {
MacOSWindowFrame(windowState, content)
}

is Platform.Windows -> {
WindowsWindowFrame(
windowState = windowState,
onCloseRequest = onCloseRequest,
content = content,
)
}

else -> {
content()
}
}
}
Loading

0 comments on commit fd0ad8a

Please sign in to comment.