├── .gitignore ├── README.md ├── app ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── ir │ └── amirab │ ├── Main.kt │ ├── Theme.kt │ └── customwindow │ ├── CustomWindow.kt │ ├── SystemButtons.kt │ └── icons │ ├── Close.kt │ ├── CustomIcons.kt │ ├── ExitMaximize.kt │ ├── Maximize.kt │ └── Minimize.kt ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── lib ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── ir │ └── amirab │ ├── ToolbarItem.kt │ └── util │ ├── CustomWindowDecorationAccessing.kt │ └── UnsafeAccessing.kt ├── settings.gradle.kts └── static └── sample.gif /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/**/build/ 5 | !**/src/test/**/build/ 6 | 7 | ### IntelliJ IDEA ### 8 | .idea/modules.xml 9 | .idea/jarRepositories.xml 10 | .idea/compiler.xml 11 | .idea/libraries/ 12 | *.iws 13 | *.iml 14 | *.ipr 15 | out/ 16 | !**/src/main/**/out/ 17 | !**/src/test/**/out/ 18 | 19 | ### Eclipse ### 20 | .apt_generated 21 | .classpath 22 | .factorypath 23 | .project 24 | .settings 25 | .springBeans 26 | .sts4-cache 27 | bin/ 28 | !**/src/main/**/bin/ 29 | !**/src/test/**/bin/ 30 | 31 | ### NetBeans ### 32 | /nbproject/private/ 33 | /nbbuild/ 34 | /dist/ 35 | /nbdist/ 36 | /.nb-gradle/ 37 | 38 | ### VS Code ### 39 | .vscode/ 40 | 41 | ### Mac OS ### 42 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Compose Custom Window Frame 2 | 3 | A simple library that add support for custom window frame for compose desktop that supports aero snap 4 | 5 | ## Demo 6 | 7 | ![Demo](/static/sample.gif) 8 | 9 | ## Usage 10 | 11 | - Please check `app` module for sample usage 12 | - I also used this in the [AB Download Manager](https://github.com/amir1376/ab-download-manager) 13 | 14 | ## Note 15 | 16 | - You need to use `JBR` (Jetbrains Runtime) for your project SDK and Gradle 17 | 18 | it is inspired by [Compose Jetbrains Theme](https://github.com/ButterCam/compose-jetbrains-theme) library. 19 | 20 | Check their library for more info. 21 | 22 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins{ 2 | kotlin("jvm") 3 | id("org.jetbrains.compose") 4 | } 5 | 6 | repositories { 7 | mavenCentral() 8 | } 9 | 10 | dependencies{ 11 | implementation(compose.desktop.currentOs) 12 | implementation(compose.materialIconsExtended) 13 | implementation(project(":lib")) 14 | } 15 | compose{ 16 | desktop{ 17 | application{ 18 | mainClass = "ir.amirab.MainKt" 19 | nativeDistributions { 20 | modules("jdk.unsupported") 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/amirab/Main.kt: -------------------------------------------------------------------------------- 1 | package ir.amirab 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.shape.CircleShape 6 | import androidx.compose.material.* 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.DarkMode 9 | import androidx.compose.material.icons.filled.LightMode 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.runtime.setValue 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.draw.clip 17 | import androidx.compose.ui.unit.dp 18 | import androidx.compose.ui.window.WindowPlacement 19 | import androidx.compose.ui.window.application 20 | import androidx.compose.ui.window.rememberWindowState 21 | import ir.amirab.Themes.* 22 | import ir.amirab.customwindow.CustomWindow 23 | import ir.amirab.customwindow.WindowCenter 24 | import ir.amirab.customwindow.WindowIcon 25 | import ir.amirab.customwindow.WindowTitle 26 | 27 | 28 | fun main() = application { 29 | 30 | val state = rememberWindowState() 31 | var selectedTheme by remember { mutableStateOf(Dark) } 32 | MaterialTheme(themes[selectedTheme]!!) { 33 | CustomWindow(state, { 34 | exitApplication() 35 | }) { 36 | WindowTitle("Custom Window") 37 | WindowCenter { 38 | Row( 39 | Modifier 40 | .fillMaxWidth() 41 | .padding(start = 16.dp), 42 | ) { 43 | Icon( 44 | when (selectedTheme) { 45 | Dark -> Icons.Default.LightMode 46 | Light -> Icons.Default.DarkMode 47 | }, 48 | null, 49 | Modifier 50 | .windowFrameItem("theme", HitSpots.OTHER_HIT_SPOT) 51 | .clickable { 52 | selectedTheme = when (selectedTheme) { 53 | Dark -> Light 54 | Light -> Dark 55 | } 56 | } 57 | .padding(4.dp) 58 | .size(16.dp) 59 | .clip(CircleShape) 60 | ) 61 | } 62 | } 63 | Column(Modifier.fillMaxSize().wrapContentSize()) { 64 | Text("Hello from compose multiplatform", style = MaterialTheme.typography.h5) 65 | Text("This window supports aero snap. drag to edge and see the results", style = MaterialTheme.typography.body1) 66 | } 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/amirab/Theme.kt: -------------------------------------------------------------------------------- 1 | package ir.amirab 2 | 3 | import androidx.compose.material.darkColors 4 | import androidx.compose.material.lightColors 5 | 6 | val themes = mapOf( 7 | Themes.Dark to darkColors(), 8 | Themes.Light to lightColors() 9 | ) 10 | 11 | enum class Themes { 12 | Dark, Light 13 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/amirab/customwindow/CustomWindow.kt: -------------------------------------------------------------------------------- 1 | package ir.amirab.customwindow 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.window.WindowDraggableArea 6 | import androidx.compose.material.Icon 7 | import androidx.compose.material.LocalContentColor 8 | import androidx.compose.material.MaterialTheme 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.* 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.painter.Painter 14 | import androidx.compose.ui.graphics.vector.ImageVector 15 | import androidx.compose.ui.platform.LocalWindowInfo 16 | import androidx.compose.ui.text.style.TextOverflow 17 | import androidx.compose.ui.unit.dp 18 | import androidx.compose.ui.unit.sp 19 | import androidx.compose.ui.window.FrameWindowScope 20 | import androidx.compose.ui.window.Window 21 | import androidx.compose.ui.window.WindowPlacement 22 | import androidx.compose.ui.window.WindowState 23 | import ir.amirab.HitSpots 24 | import ir.amirab.ProvideWindowSpotContainer 25 | import ir.amirab.util.CustomWindowDecorationAccessing 26 | import ir.amirab.windowFrameItem 27 | 28 | 29 | // a window frame which totally rendered with compose 30 | @Composable 31 | private fun FrameWindowScope.CustomWindowFrame( 32 | onRequestMinimize: (() -> Unit)?, 33 | onRequestClose: () -> Unit, 34 | onRequestToggleMaximize: (() -> Unit)?, 35 | title: String, 36 | windowIcon: ImageVector? = null, 37 | center: @Composable () -> Unit, 38 | content: @Composable () -> Unit, 39 | ) { 40 | CompositionLocalProvider( 41 | LocalContentColor provides MaterialTheme.colors.onBackground, 42 | ) { 43 | Column( 44 | Modifier 45 | .fillMaxSize() 46 | .background(MaterialTheme.colors.surface) 47 | ) { 48 | SnapDraggableToolbar( 49 | title = title, 50 | windowIcon = windowIcon, 51 | center = center, 52 | onRequestMinimize = onRequestMinimize, 53 | onRequestClose = onRequestClose, 54 | onRequestToggleMaximize = onRequestToggleMaximize, 55 | windowState = LocalWindowState.current 56 | ) 57 | content() 58 | } 59 | } 60 | } 61 | 62 | @Composable 63 | fun isWindowFocused(): Boolean { 64 | return LocalWindowInfo.current.isWindowFocused 65 | } 66 | 67 | @Composable 68 | fun isWindowMaximized(): Boolean { 69 | return LocalWindowState.current.placement == WindowPlacement.Maximized 70 | } 71 | 72 | @Composable 73 | fun isWindowFloating(): Boolean { 74 | return LocalWindowState.current.placement == WindowPlacement.Floating 75 | } 76 | 77 | @Composable 78 | fun FrameWindowScope.SnapDraggableToolbar( 79 | title: String, 80 | windowIcon: ImageVector? = null, 81 | center: @Composable () -> Unit, 82 | onRequestMinimize: (() -> Unit)?, 83 | onRequestToggleMaximize: (() -> Unit)?, 84 | onRequestClose: () -> Unit, 85 | windowState: WindowState, 86 | ) { 87 | ProvideWindowSpotContainer( 88 | windowState = windowState 89 | ) { 90 | if (CustomWindowDecorationAccessing.isSupported) { 91 | FrameContent(title, windowIcon, center, onRequestMinimize, onRequestToggleMaximize, onRequestClose) 92 | } else { 93 | WindowDraggableArea { 94 | FrameContent(title, windowIcon, center, onRequestMinimize, onRequestToggleMaximize, onRequestClose) 95 | } 96 | } 97 | } 98 | } 99 | 100 | @Composable 101 | private fun FrameWindowScope.FrameContent( 102 | title: String, 103 | windowIcon: ImageVector? = null, 104 | center: @Composable () -> Unit, 105 | onRequestMinimize: (() -> Unit)?, 106 | onRequestToggleMaximize: (() -> Unit)?, 107 | onRequestClose: () -> Unit, 108 | ) { 109 | Row( 110 | Modifier.fillMaxWidth(), 111 | verticalAlignment = Alignment.CenterVertically, 112 | ) { 113 | Spacer(Modifier.width(24.dp)) 114 | windowIcon?.let { 115 | Icon(it, null) 116 | Spacer(Modifier.width(8.dp)) 117 | } 118 | CompositionLocalProvider( 119 | LocalContentColor provides MaterialTheme.colors.onBackground.copy(0.75f) 120 | ) { 121 | Text( 122 | title, maxLines = 1, 123 | overflow = TextOverflow.Ellipsis, 124 | fontSize = 12.sp, 125 | modifier = Modifier 126 | .windowFrameItem("title", HitSpots.DRAGGABLE_AREA) 127 | ) 128 | } 129 | Box(Modifier.weight(1f)) { 130 | center() 131 | } 132 | WindowsActionButtons( 133 | onRequestClose, 134 | onRequestMinimize, 135 | onRequestToggleMaximize, 136 | ) 137 | } 138 | } 139 | 140 | @Composable 141 | fun CustomWindow( 142 | state: WindowState, 143 | onCloseRequest: () -> Unit, 144 | onRequestMinimize: (() -> Unit)? = { 145 | state.isMinimized = true 146 | }, 147 | onRequestToggleMaximize: (() -> Unit)? = { 148 | if (state.placement == WindowPlacement.Maximized) { 149 | state.placement = WindowPlacement.Floating 150 | } else { 151 | state.placement = WindowPlacement.Maximized 152 | } 153 | }, 154 | defaultTitle: String = "Untitled", 155 | defaultIcon: Painter? = null, 156 | content: @Composable FrameWindowScope.() -> Unit, 157 | ) { 158 | //two-way binding 159 | val windowController = remember { 160 | WindowController() 161 | } 162 | val center = windowController.center ?: {} 163 | 164 | 165 | val transparent: Boolean 166 | val undecorated: Boolean 167 | val isAeroSnapSupported = CustomWindowDecorationAccessing.isSupported 168 | if (isAeroSnapSupported) { 169 | //we use aero snap 170 | transparent = false 171 | undecorated = false 172 | } else { 173 | //we decorate window and add our custom layout 174 | transparent = true 175 | undecorated = true 176 | } 177 | Window( 178 | state = state, 179 | transparent = transparent, 180 | undecorated = undecorated, 181 | icon = defaultIcon, 182 | onCloseRequest = onCloseRequest, 183 | ) { 184 | val title = windowController.title ?: defaultTitle 185 | LaunchedEffect(title) { 186 | window.title = title 187 | } 188 | CompositionLocalProvider( 189 | LocalWindowController provides windowController, 190 | LocalWindowState provides state, 191 | ) { 192 | val icon by rememberUpdatedState(windowController.icon) 193 | val onIconClick by rememberUpdatedState(windowController.onIconClick) 194 | // a window frame which totally rendered with compose 195 | CustomWindowFrame( 196 | onRequestMinimize = onRequestMinimize, 197 | onRequestClose = onCloseRequest, 198 | onRequestToggleMaximize = onRequestToggleMaximize, 199 | title = title, 200 | windowIcon = icon, 201 | center = { center() } 202 | ) { 203 | content() 204 | } 205 | } 206 | } 207 | } 208 | 209 | 210 | class WindowController { 211 | var title by mutableStateOf(null as String?) 212 | var icon by mutableStateOf(null as ImageVector?) 213 | var onIconClick by mutableStateOf(null as (() -> Unit)?) 214 | var center: (@Composable () -> Unit)? by mutableStateOf(null) 215 | } 216 | 217 | private val LocalWindowController = compositionLocalOf { error("window controller not provided") } 218 | private val LocalWindowState = compositionLocalOf { error("window controller not provided") } 219 | 220 | @Composable 221 | fun WindowCenter(content: @Composable () -> Unit) { 222 | val c = LocalWindowController.current 223 | c.center = content 224 | DisposableEffect(Unit) { 225 | onDispose { 226 | c.center = null 227 | } 228 | } 229 | } 230 | 231 | @Composable 232 | fun WindowTitle(title: String) { 233 | val c = LocalWindowController.current 234 | LaunchedEffect(title) { 235 | c.title = title 236 | } 237 | DisposableEffect(Unit) { 238 | onDispose { 239 | c.title = null 240 | } 241 | } 242 | } 243 | 244 | @Composable 245 | fun WindowIcon(icon: ImageVector, onClick: () -> Unit) { 246 | val current = LocalWindowController.current 247 | DisposableEffect(icon) { 248 | current.let { 249 | it.icon = icon 250 | it.onIconClick = onClick 251 | } 252 | onDispose { 253 | current.let { 254 | it.icon = null 255 | it.onIconClick = null 256 | } 257 | } 258 | } 259 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/amirab/customwindow/SystemButtons.kt: -------------------------------------------------------------------------------- 1 | package ir.amirab.customwindow 2 | 3 | import androidx.compose.animation.animateColorAsState 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.hoverable 7 | import androidx.compose.foundation.interaction.MutableInteractionSource 8 | import androidx.compose.foundation.interaction.collectIsHoveredAsState 9 | import androidx.compose.foundation.layout.* 10 | import androidx.compose.material.Icon 11 | import androidx.compose.material.LocalContentColor 12 | import androidx.compose.material.MaterialTheme 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.graphics.painter.Painter 20 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 21 | import androidx.compose.ui.unit.dp 22 | import androidx.compose.ui.window.FrameWindowScope 23 | import ir.amirab.HitSpots 24 | import ir.amirab.customwindow.icons.* 25 | import ir.amirab.windowFrameItem 26 | 27 | @Composable 28 | fun SystemButton( 29 | onClick: () -> Unit, 30 | background: Color = Color.Transparent, 31 | onBackground: Color = LocalContentColor.current, 32 | hoveredBackgroundColor: Color = background, 33 | onHoveredBackgroundColor: Color = LocalContentColor.current, 34 | icon: Painter, 35 | modifier: Modifier = Modifier, 36 | ) { 37 | val isFocused = isWindowFocused() 38 | val interactionSource = remember { MutableInteractionSource() } 39 | val isHovered by interactionSource.collectIsHoveredAsState() 40 | Icon( 41 | painter = icon, 42 | contentDescription = null, 43 | tint = animateColorAsState( 44 | when { 45 | isHovered -> onHoveredBackgroundColor 46 | else -> onBackground 47 | }.copy( 48 | alpha = if (isFocused || isHovered) { 49 | 1f 50 | } else { 51 | 0.25f 52 | } 53 | ) 54 | ).value, 55 | modifier = modifier 56 | .clickable { onClick() } 57 | .background( 58 | animateColorAsState( 59 | when { 60 | isHovered -> hoveredBackgroundColor 61 | else -> background 62 | } 63 | ).value 64 | ) 65 | .hoverable(interactionSource) 66 | .windowButton() 67 | ) 68 | } 69 | 70 | 71 | @Composable 72 | fun CloseButton( 73 | onRequestClose: () -> Unit, 74 | modifier: Modifier, 75 | ) { 76 | SystemButton( 77 | onRequestClose, 78 | background = Color.Transparent, 79 | onBackground = MaterialTheme.colors.onBackground, 80 | hoveredBackgroundColor = Color(0xFFc42b1c), 81 | onHoveredBackgroundColor = Color.White, 82 | icon = rememberVectorPainter(CustomIcons.Close), 83 | modifier=modifier, 84 | ) 85 | } 86 | 87 | private fun Modifier.windowButton(): Modifier { 88 | return padding( 89 | vertical = 10.dp, horizontal = 20.dp 90 | ).size(8.dp) 91 | } 92 | 93 | @Composable 94 | fun FrameWindowScope.WindowsActionButtons( 95 | onRequestClose: () -> Unit, 96 | onRequestMinimize: (() -> Unit)?, 97 | onToggleMaximize: (() -> Unit)?, 98 | ) { 99 | Row( 100 | // Toolbar is aligned center vertically so I fill that and place it on top 101 | modifier = Modifier, 102 | verticalAlignment = Alignment.Top 103 | ) { 104 | onRequestMinimize?.let { 105 | SystemButton( 106 | icon = rememberVectorPainter(CustomIcons.Minimize), 107 | onClick = onRequestMinimize, 108 | modifier = Modifier.windowFrameItem("minimize", HitSpots.MINIMIZE_BUTTON) 109 | ) 110 | } 111 | 112 | onToggleMaximize?.let { 113 | SystemButton( 114 | icon = rememberVectorPainter( 115 | if (isWindowMaximized()) { 116 | CustomIcons.Floating 117 | } else { 118 | CustomIcons.Maximize 119 | } 120 | ), 121 | onClick = onToggleMaximize, 122 | modifier = Modifier.windowFrameItem("maximize", HitSpots.MAXIMIZE_BUTTON) 123 | ) 124 | } 125 | 126 | CloseButton( 127 | onRequestClose=onRequestClose, 128 | modifier = Modifier.windowFrameItem("close", HitSpots.CLOSE_BUTTON) 129 | ) 130 | } 131 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/amirab/customwindow/icons/Close.kt: -------------------------------------------------------------------------------- 1 | package ir.amirab.customwindow.icons 2 | 3 | import androidx.compose.ui.graphics.SolidColor 4 | import androidx.compose.ui.graphics.Color 5 | import androidx.compose.ui.graphics.StrokeCap 6 | import androidx.compose.ui.graphics.StrokeJoin 7 | import androidx.compose.ui.graphics.vector.ImageVector 8 | import androidx.compose.ui.graphics.PathFillType 9 | import androidx.compose.ui.graphics.vector.path 10 | import androidx.compose.ui.unit.dp 11 | 12 | 13 | private var _vector: ImageVector? = null 14 | 15 | public val CustomIcons.Close: ImageVector 16 | get() { 17 | if (_vector != null) { 18 | return _vector!! 19 | } 20 | _vector = ImageVector.Builder( 21 | name = "vector", 22 | defaultWidth = 24.dp, 23 | defaultHeight = 24.dp, 24 | viewportWidth = 24f, 25 | viewportHeight = 24f 26 | ).apply { 27 | path( 28 | fill = SolidColor(Color(0xFFFFFFFF)), 29 | fillAlpha = 1.0f, 30 | stroke = null, 31 | strokeAlpha = 1.0f, 32 | strokeLineWidth = 1.0f, 33 | strokeLineCap = StrokeCap.Butt, 34 | strokeLineJoin = StrokeJoin.Miter, 35 | strokeLineMiter = 1.0f, 36 | pathFillType = PathFillType.NonZero 37 | ) { 38 | moveTo(0f, 22f) 39 | lineTo(22f, 0f) 40 | lineTo(24f, 2f) 41 | lineTo(2f, 24f) 42 | lineTo(0f, 22f) 43 | close() 44 | } 45 | path( 46 | fill = SolidColor(Color(0xFFFFFFFF)), 47 | fillAlpha = 1.0f, 48 | stroke = null, 49 | strokeAlpha = 1.0f, 50 | strokeLineWidth = 1.0f, 51 | strokeLineCap = StrokeCap.Butt, 52 | strokeLineJoin = StrokeJoin.Miter, 53 | strokeLineMiter = 1.0f, 54 | pathFillType = PathFillType.NonZero 55 | ) { 56 | moveTo(22f, 24f) 57 | lineTo(0f, 2f) 58 | lineTo(2f, 0f) 59 | lineTo(24f, 22f) 60 | lineTo(22f, 24f) 61 | close() 62 | } 63 | }.build() 64 | return _vector!! 65 | } 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/amirab/customwindow/icons/CustomIcons.kt: -------------------------------------------------------------------------------- 1 | package ir.amirab.customwindow.icons 2 | 3 | object CustomIcons 4 | -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/amirab/customwindow/icons/ExitMaximize.kt: -------------------------------------------------------------------------------- 1 | package ir.amirab.customwindow.icons 2 | import androidx.compose.ui.graphics.SolidColor 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.graphics.StrokeCap 5 | import androidx.compose.ui.graphics.StrokeJoin 6 | import androidx.compose.ui.graphics.vector.ImageVector 7 | import androidx.compose.ui.graphics.PathFillType 8 | import androidx.compose.ui.graphics.vector.path 9 | import androidx.compose.ui.unit.dp 10 | 11 | 12 | private var _vector: ImageVector? = null 13 | 14 | public val CustomIcons.Floating: ImageVector 15 | get() { 16 | if (_vector != null) { 17 | return _vector!! 18 | } 19 | _vector = ImageVector.Builder( 20 | name = "vector", 21 | defaultWidth = 24.dp, 22 | defaultHeight = 24.dp, 23 | viewportWidth = 24f, 24 | viewportHeight = 24f 25 | ).apply { 26 | path( 27 | fill = SolidColor(Color(0xFFFFFFFF)), 28 | fillAlpha = 1.0f, 29 | stroke = null, 30 | strokeAlpha = 1.0f, 31 | strokeLineWidth = 1.0f, 32 | strokeLineCap = StrokeCap.Butt, 33 | strokeLineJoin = StrokeJoin.Miter, 34 | strokeLineMiter = 1.0f, 35 | pathFillType = PathFillType.EvenOdd 36 | ) { 37 | moveTo(6.54545f, 0f) 38 | verticalLineTo(6.54545f) 39 | horizontalLineTo(0f) 40 | verticalLineTo(24f) 41 | horizontalLineTo(17.4545f) 42 | verticalLineTo(17.4545f) 43 | horizontalLineTo(24f) 44 | verticalLineTo(0f) 45 | horizontalLineTo(6.54545f) 46 | close() 47 | moveTo(21.8182f, 2.18182f) 48 | horizontalLineTo(8.72727f) 49 | verticalLineTo(6.54545f) 50 | horizontalLineTo(17.4545f) 51 | verticalLineTo(15.2727f) 52 | horizontalLineTo(21.8182f) 53 | verticalLineTo(2.18182f) 54 | close() 55 | moveTo(15.2727f, 15.2727f) 56 | verticalLineTo(21.8182f) 57 | horizontalLineTo(2.18182f) 58 | verticalLineTo(8.72727f) 59 | horizontalLineTo(8.72727f) 60 | horizontalLineTo(15.2727f) 61 | verticalLineTo(15.2727f) 62 | close() 63 | } 64 | }.build() 65 | return _vector!! 66 | } 67 | 68 | -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/amirab/customwindow/icons/Maximize.kt: -------------------------------------------------------------------------------- 1 | package ir.amirab.customwindow.icons 2 | 3 | import androidx.compose.ui.graphics.SolidColor 4 | import androidx.compose.ui.graphics.Color 5 | import androidx.compose.ui.graphics.StrokeCap 6 | import androidx.compose.ui.graphics.StrokeJoin 7 | import androidx.compose.ui.graphics.vector.ImageVector 8 | import androidx.compose.ui.graphics.PathFillType 9 | import androidx.compose.ui.graphics.vector.path 10 | import androidx.compose.ui.unit.dp 11 | 12 | 13 | private var _vector: ImageVector? = null 14 | 15 | public val CustomIcons.Maximize: ImageVector 16 | get() { 17 | if (_vector != null) { 18 | return _vector!! 19 | } 20 | _vector = ImageVector.Builder( 21 | name = "vector", 22 | defaultWidth = 24.dp, 23 | defaultHeight = 24.dp, 24 | viewportWidth = 24f, 25 | viewportHeight = 24f 26 | ).apply { 27 | path( 28 | fill = SolidColor(Color(0xFFFFFFFF)), 29 | fillAlpha = 1.0f, 30 | stroke = null, 31 | strokeAlpha = 1.0f, 32 | strokeLineWidth = 1.0f, 33 | strokeLineCap = StrokeCap.Butt, 34 | strokeLineJoin = StrokeJoin.Miter, 35 | strokeLineMiter = 1.0f, 36 | pathFillType = PathFillType.EvenOdd 37 | ) { 38 | moveTo(21.8182f, 2.18182f) 39 | horizontalLineTo(2.18182f) 40 | verticalLineTo(21.8182f) 41 | horizontalLineTo(21.8182f) 42 | verticalLineTo(2.18182f) 43 | close() 44 | moveTo(0f, 0f) 45 | verticalLineTo(24f) 46 | horizontalLineTo(24f) 47 | verticalLineTo(0f) 48 | horizontalLineTo(0f) 49 | close() 50 | } 51 | }.build() 52 | return _vector!! 53 | } 54 | 55 | -------------------------------------------------------------------------------- /app/src/main/kotlin/ir/amirab/customwindow/icons/Minimize.kt: -------------------------------------------------------------------------------- 1 | package ir.amirab.customwindow.icons 2 | 3 | 4 | import androidx.compose.ui.graphics.SolidColor 5 | import androidx.compose.ui.graphics.Color 6 | import androidx.compose.ui.graphics.StrokeCap 7 | import androidx.compose.ui.graphics.StrokeJoin 8 | import androidx.compose.ui.graphics.vector.ImageVector 9 | import androidx.compose.ui.graphics.PathFillType 10 | import androidx.compose.ui.graphics.vector.path 11 | import androidx.compose.ui.unit.dp 12 | 13 | 14 | private var _vector: ImageVector? = null 15 | 16 | public val CustomIcons.Minimize: ImageVector 17 | get() { 18 | if (_vector != null) { 19 | return _vector!! 20 | } 21 | _vector = ImageVector.Builder( 22 | name = "vector", 23 | defaultWidth = 24.dp, 24 | defaultHeight = 24.dp, 25 | viewportWidth = 24f, 26 | viewportHeight = 24f 27 | ).apply { 28 | path( 29 | fill = SolidColor(Color(0xFFFFFFFF)), 30 | fillAlpha = 1.0f, 31 | stroke = null, 32 | strokeAlpha = 1.0f, 33 | strokeLineWidth = 1.0f, 34 | strokeLineCap = StrokeCap.Butt, 35 | strokeLineJoin = StrokeJoin.Miter, 36 | strokeLineMiter = 1.0f, 37 | pathFillType = PathFillType.NonZero 38 | ) { 39 | moveTo(0f, 10.9091f) 40 | horizontalLineTo(24f) 41 | verticalLineTo(13.0909f) 42 | horizontalLineTo(0f) 43 | verticalLineTo(10.9091f) 44 | close() 45 | } 46 | }.build() 47 | return _vector!! 48 | } 49 | 50 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") version "1.9.20" apply false 3 | id("org.jetbrains.compose") version "1.5.10" apply false 4 | } 5 | 6 | group = "ir.amirab" 7 | version = "1.0.4" 8 | 9 | repositories { 10 | mavenCentral() 11 | } 12 | subprojects{ 13 | tasks.withType().configureEach { 14 | val optIns= listOf( 15 | ).map { 16 | "-Xopt-in=$it" 17 | } 18 | val contextReceivers="-Xcontext-receivers" 19 | kotlinOptions { 20 | freeCompilerArgs += optIns+contextReceivers 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir1376/compose-custom-window-frame/55044e39b3bf3b7b7404f6677c6beb549052d4c4/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Use "xargs" to parse quoted args. 209 | # 210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 211 | # 212 | # In Bash we could simply go: 213 | # 214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 215 | # set -- "${ARGS[@]}" "$@" 216 | # 217 | # but POSIX shell has neither arrays nor command substitution, so instead we 218 | # post-process each arg (as a line of input to sed) to backslash-escape any 219 | # character that might be a shell metacharacter, then use eval to reverse 220 | # that process (while maintaining the separation between arguments), and wrap 221 | # the whole thing up as a single "set" statement. 222 | # 223 | # This will of course break if any of these variables contains a newline or 224 | # an unmatched quote. 225 | # 226 | 227 | eval "set -- $( 228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 229 | xargs -n1 | 230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 231 | tr '\n' ' ' 232 | )" '"$@"' 233 | 234 | exec "$JAVACMD" "$@" 235 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /lib/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins{ 2 | kotlin("jvm") 3 | id("org.jetbrains.compose") 4 | } 5 | 6 | repositories { 7 | mavenCentral() 8 | } 9 | 10 | dependencies{ 11 | implementation(compose.desktop.common) 12 | } -------------------------------------------------------------------------------- /lib/src/main/kotlin/ir/amirab/ToolbarItem.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("unused") 2 | 3 | package ir.amirab 4 | 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.runtime.* 7 | import androidx.compose.ui.ExperimentalComposeUiApi 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.composed 10 | import androidx.compose.ui.geometry.Offset 11 | import androidx.compose.ui.layout.onGloballyPositioned 12 | import androidx.compose.ui.layout.positionInWindow 13 | import androidx.compose.ui.platform.LocalDensity 14 | import androidx.compose.ui.platform.LocalWindowInfo 15 | import androidx.compose.ui.unit.* 16 | import androidx.compose.ui.window.FrameWindowScope 17 | import androidx.compose.ui.window.WindowState 18 | import ir.amirab.util.CustomWindowDecorationAccessing 19 | import java.awt.Rectangle 20 | import java.awt.Shape 21 | import java.awt.Window 22 | import java.awt.event.ComponentAdapter 23 | import java.awt.event.ComponentEvent 24 | 25 | object HitSpots { 26 | const val NO_HIT_SPOT = 0 27 | const val OTHER_HIT_SPOT = 1 28 | const val MINIMIZE_BUTTON = 2 29 | const val MAXIMIZE_BUTTON = 3 30 | const val CLOSE_BUTTON = 4 31 | const val MENU_BAR = 5 32 | const val DRAGGABLE_AREA = 6 33 | } 34 | 35 | 36 | private val LocalWindowHitSpots = 37 | compositionLocalOf>> { error("LocalWindowHitSpots not provided") } 38 | 39 | @Composable 40 | private fun FrameWindowScope.getCurrentWindowSize(): DpSize { 41 | var windowSize by remember { 42 | mutableStateOf(DpSize(window.width.dp, window.height.dp)) 43 | } 44 | //observe window size 45 | DisposableEffect(window) { 46 | val listener = object : ComponentAdapter() { 47 | override fun componentResized(p0: ComponentEvent?) { 48 | windowSize = DpSize(window.width.dp, window.height.dp) 49 | } 50 | } 51 | window.addComponentListener(listener) 52 | onDispose { 53 | window.removeComponentListener(listener) 54 | } 55 | } 56 | return windowSize 57 | } 58 | 59 | context (FrameWindowScope) 60 | @Composable 61 | private fun Modifier.onPositionInRect( 62 | onChange: (Rectangle) -> Unit, 63 | ) = composed { 64 | val density = LocalDensity.current 65 | onGloballyPositioned { 66 | onChange( 67 | it.positionInWindow().toDpRectangle( 68 | width = it.size.width, 69 | height = it.size.height, 70 | density = density 71 | ) 72 | ) 73 | } 74 | } 75 | 76 | 77 | private fun Offset.toDpRectangle( 78 | width: Int, 79 | height: Int, 80 | density: Density, 81 | ): Rectangle { 82 | val offset = this 83 | density.run { 84 | return Rectangle( 85 | (offset.x).toAwtUnitSize(), 86 | offset.y.toAwtUnitSize(), 87 | width.toAwtUnitSize(), 88 | height.toAwtUnitSize(), 89 | ) 90 | } 91 | } 92 | 93 | /** 94 | * dp as int 95 | */ 96 | context (Density) 97 | private fun Int.toAwtUnitSize() = toDp().value.toInt() 98 | 99 | context (Density) 100 | private fun Float.toAwtUnitSize() = toDp().value.toInt() 101 | 102 | 103 | private fun placeHitSpots( 104 | window: Window, 105 | spots: Map, 106 | height: Int, 107 | ) { 108 | CustomWindowDecorationAccessing.setCustomDecorationEnabled(window, true) 109 | CustomWindowDecorationAccessing.setCustomDecorationTitleBarHeight( 110 | window, 111 | height, 112 | ) 113 | CustomWindowDecorationAccessing.setCustomDecorationHitTestSpotsMethod(window, spots) 114 | } 115 | 116 | 117 | context (FrameWindowScope) 118 | @OptIn(ExperimentalComposeUiApi::class) 119 | @Composable 120 | fun ProvideWindowSpotContainer( 121 | windowState: WindowState, 122 | content: @Composable () -> Unit, 123 | ) { 124 | val density = LocalDensity.current 125 | val windowSize =getCurrentWindowSize() 126 | val containerSize = with(density) { 127 | LocalWindowInfo.current.containerSize.let { 128 | DpSize(it.width.toDp(), it.height.toDp()) 129 | } 130 | } 131 | // we pass it to composition local provider 132 | val spotInfoState = remember { 133 | mutableStateMapOf>() 134 | } 135 | var toolbarHeight by remember { 136 | mutableStateOf(0) 137 | } 138 | 139 | val spotsWithInfo = spotInfoState.toMap() 140 | var shouldRestorePlacement by remember(window) { 141 | mutableStateOf(true) 142 | } 143 | //if any of this keys change we will re position hit spots 144 | LaunchedEffect( 145 | spotsWithInfo, 146 | toolbarHeight, 147 | window, 148 | windowSize, 149 | containerSize, 150 | ) { 151 | // 152 | if (CustomWindowDecorationAccessing.isSupported) { 153 | val startOffset = (windowSize - containerSize) / 2 154 | val startWidthOffsetInDp = startOffset.width.value.toInt() 155 | // val startHeightInDp=delta.height.value.toInt() //it seems no need here 156 | val spots: Map = spotsWithInfo.values.associate { (rect, spot) -> 157 | Rectangle(rect.x + startWidthOffsetInDp, rect.y, rect.width, rect.height) to spot 158 | } 159 | //it seems after activating hit spots window class will change its placement 160 | //we only want to restore placement whe windows is loaded for first time 161 | if (shouldRestorePlacement){ 162 | //this block only called once for each window 163 | val lastPlacement=windowState.placement 164 | placeHitSpots(window, spots, toolbarHeight) 165 | window.placement=lastPlacement 166 | shouldRestorePlacement=false 167 | }else{ 168 | placeHitSpots(window, spots, toolbarHeight) 169 | } 170 | 171 | } 172 | } 173 | CompositionLocalProvider( 174 | LocalWindowHitSpots provides spotInfoState 175 | ) { 176 | Box(Modifier.onGloballyPositioned { 177 | toolbarHeight = with(density) { 178 | it.size.height.toAwtUnitSize() 179 | } 180 | }) { 181 | content() 182 | } 183 | } 184 | } 185 | 186 | context (FrameWindowScope) 187 | @Composable 188 | fun Modifier.windowFrameItem( 189 | key: Any, 190 | spot: Int, 191 | ) = composed { 192 | var shape by remember(key) { 193 | mutableStateOf(null as Rectangle?) 194 | } 195 | val localWindowSpots = LocalWindowHitSpots.current 196 | DisposableEffect(shape, key) { 197 | shape.let { shape -> 198 | if (shape != null) { 199 | localWindowSpots[key] = shape to spot 200 | onDispose { 201 | localWindowSpots.remove(key) 202 | } 203 | } else { 204 | onDispose {} 205 | } 206 | } 207 | } 208 | onPositionInRect { shape = it } 209 | } -------------------------------------------------------------------------------- /lib/src/main/kotlin/ir/amirab/util/CustomWindowDecorationAccessing.kt: -------------------------------------------------------------------------------- 1 | package ir.amirab.util 2 | 3 | import java.awt.Shape 4 | import java.awt.Window 5 | import java.lang.reflect.Method 6 | 7 | object CustomWindowDecorationAccessing { 8 | init { 9 | UnsafeAccessing.assignAccessibility( 10 | UnsafeAccessing.desktopModule, 11 | listOf( 12 | "java.awt" 13 | ) 14 | ) 15 | } 16 | 17 | private val customWindowDecorationInstance: Any? = try { 18 | val customWindowDecoration = Class.forName("java.awt.Window\$CustomWindowDecoration") 19 | val constructor = customWindowDecoration.declaredConstructors.first() 20 | constructor.isAccessible = true 21 | constructor.newInstance() 22 | } catch (e: Exception) { 23 | null 24 | } 25 | 26 | private val setCustomDecorationEnabledMethod: Method? = 27 | getMethod("setCustomDecorationEnabled", Window::class.java, Boolean::class.java) 28 | 29 | private val setCustomDecorationTitleBarHeightMethod: Method? = 30 | getMethod("setCustomDecorationTitleBarHeight", Window::class.java, Int::class.java) 31 | 32 | private val setCustomDecorationHitTestSpotsMethod: Method? = 33 | getMethod("setCustomDecorationHitTestSpots", Window::class.java, MutableList::class.java) 34 | 35 | private fun getMethod(name: String, vararg params: Class<*>): Method? { 36 | return try { 37 | val clazz = Class.forName("java.awt.Window\$CustomWindowDecoration") 38 | val method = clazz.getDeclaredMethod( 39 | name, *params 40 | ) 41 | method.isAccessible = true 42 | method 43 | } catch (e: Exception) { 44 | null 45 | } 46 | } 47 | 48 | val isSupported = customWindowDecorationInstance != null && setCustomDecorationEnabledMethod != null 49 | 50 | internal fun setCustomDecorationEnabled(window: Window, enabled: Boolean) { 51 | val instance = customWindowDecorationInstance ?: return 52 | val method = setCustomDecorationEnabledMethod ?: return 53 | method.invoke(instance, window, enabled) 54 | } 55 | 56 | internal fun setCustomDecorationTitleBarHeight(window: Window, height: Int) { 57 | val instance = customWindowDecorationInstance ?: return 58 | val method = setCustomDecorationTitleBarHeightMethod ?: return 59 | method.invoke(instance, window, height) 60 | } 61 | 62 | internal fun setCustomDecorationHitTestSpotsMethod(window: Window, spots: Map) { 63 | val instance = customWindowDecorationInstance ?: return 64 | val method = setCustomDecorationHitTestSpotsMethod ?: return 65 | method.invoke(instance, window, spots.entries.toMutableList()) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/ir/amirab/util/UnsafeAccessing.kt: -------------------------------------------------------------------------------- 1 | package ir.amirab.util 2 | import sun.misc.Unsafe 3 | import java.lang.reflect.AccessibleObject 4 | 5 | internal object UnsafeAccessing { 6 | private val unsafe: Any? by lazy { 7 | try { 8 | val theUnsafe = Unsafe::class.java.getDeclaredField("theUnsafe") 9 | theUnsafe.isAccessible = true 10 | theUnsafe.get(null) as Unsafe 11 | } catch (e: Throwable) { 12 | null 13 | } 14 | } 15 | 16 | val desktopModule by lazy { 17 | ModuleLayer.boot().findModule("java.desktop").get() 18 | } 19 | 20 | val ownerModule by lazy { 21 | this.javaClass.module 22 | } 23 | 24 | private val isAccessibleFieldOffset: Long? by lazy { 25 | try { 26 | (unsafe as? Unsafe)?.objectFieldOffset(Parent::class.java.getDeclaredField("first")) 27 | } catch (e: Throwable) { 28 | null 29 | } 30 | } 31 | 32 | private val implAddOpens by lazy { 33 | try { 34 | Module::class.java.getDeclaredMethod( 35 | "implAddOpens", String::class.java, Module::class.java 36 | ).accessible() 37 | } catch (e: Throwable) { 38 | null 39 | } 40 | } 41 | 42 | fun assignAccessibility(obj: AccessibleObject) { 43 | try { 44 | val theUnsafe = unsafe as? Unsafe ?: return 45 | val offset = isAccessibleFieldOffset ?: return 46 | theUnsafe.putBooleanVolatile(obj, offset, true) 47 | } catch (e: Throwable) { 48 | // ignore 49 | } 50 | } 51 | 52 | fun assignAccessibility(module: Module, packages: List) { 53 | try { 54 | packages.forEach { 55 | implAddOpens?.invoke(module, it, ownerModule) 56 | } 57 | } catch (e: Throwable) { 58 | // ignore 59 | } 60 | } 61 | 62 | private class Parent { 63 | var first = false 64 | 65 | @Volatile 66 | var second: Any? = null 67 | } 68 | } 69 | 70 | internal fun T.accessible(): T { 71 | return apply { 72 | UnsafeAccessing.assignAccessibility(this) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | mavenCentral() 4 | gradlePluginPortal() 5 | } 6 | } 7 | 8 | plugins { 9 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" 10 | } 11 | 12 | rootProject.name = "compose-custom-window" 13 | include(":app") 14 | include(":lib") -------------------------------------------------------------------------------- /static/sample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amir1376/compose-custom-window-frame/55044e39b3bf3b7b7404f6677c6beb549052d4c4/static/sample.gif --------------------------------------------------------------------------------