
Customizable window frame for applications with a modern, native-style title bar: platform-aware controls, integrated material menus, theme-aware styling, rounded corners, and correct maximize/fullscreen behavior.
A customizable window frame library for Compose Multiplatform desktop applications. Provides a modern, native-looking window with a Compose-drawn title bar and standard window controls.
org.gnome.desktop.wm.preferences button-layout) decides both the side and which buttons exist —
right on GNOME/KDE/XFCE (GNOME ships with Close only), left on elementary OS and the old Unity
layout. Falls back to the right-hand layout if the setting can't be read.WindowPlacement.Maximized — respects the taskbar/dock and multi-monitor
setups; on macOS the green button zooms, and MaximizeAction.Fullscreen covers the whole screen
even though AppKit will not give an undecorated window its native fullscreen (Zoom and
fullscreen)actions slot in the title barJMenuBar strip that ignores your theme (Menus)MaterialTheme
Canvas
Window blockThe library is on Maven Central, so no extra repository is needed:
desktopMain.dependencies {
implementation("io.github.youndie:appframe:0.2.0")
}Gradle resolves appframe to its desktop variant through the module metadata, in a multiplatform
module and in a plain kotlin("jvm") one alike. A build that reads only the POM — Maven, for
one — takes the variant directly: io.github.youndie:appframe-desktop:0.2.0.
Requirements: Java 21 (the published metadata says so, and Gradle refuses the dependency on an older toolchain rather than failing at class loading) and Compose Multiplatform 1.12, which the library is compiled against.
The 0.1.x builds were published to https://reposilite.kotlin.website/releases as
appframe-desktop only; that repository can be dropped when moving to 0.2.0.
Every push to main still goes there, as <next release>.<run> — 0.2.1.<run> is a build on the
way to 0.2.1, not a release. In Maven's ordering it sorts above 0.2.1 itself, so a build that
takes one is not offered the release as an upgrade: moving to Maven Central is a hand edit.
0.2.0.28–0.2.0.30 are builds of 0.2.0 and the two commits after it, published before the
head moved; nothing needs them.
fun main() = application {
MaterialTheme {
AppFrame(onCloseRequest = ::exitApplication, title = "My App") {
App(modifier = Modifier.fillMaxSize())
}
}
}AppFrame uses TitleBarStyle.forHost() by default. Any of the built-in styles — or a copy() of
one — can be passed in, which is handy for demos and screenshots:
AppFrame(
onCloseRequest = ::exitApplication,
style = TitleBarStyle.Windows, // .MacOs, .Linux, or forHost(HostOs.Linux)
actions = {
IconButton(onClick = ::openSettings) { /* … */ }
},
) { /* content */ }Every visual aspect is a field on TitleBarStyle: bar height, which side the controls sit on, their
order, appearance (Bars or TrafficLights), button and glyph size, spacing, title alignment,
window corner radius, and whether the middle button maximizes or goes fullscreen.
One caveat when swapping styles at runtime: cornerRadius > 0 makes AppFrame ask for a
transparent window, and transparency cannot change after a window is shown. Only the style used on
the first composition decides whether the window can be rounded; later styles animate the radius
inside that decision (and are ignored if the window started opaque).
The title bar can also be used on its own inside a hand-rolled Window via the TitleBar
composable, and WindowState.toggleMaximized() / isMaximized / isFullscreen are public helpers.
Provide LocalAppFrameWindow around it so its zoom button can reach the window.
menuBar draws Material menus in the title bar, laid out after the app icon — the arrangement of
every single-row title bar with menus, from VS Code to a GNOME header bar:
AppFrame(
onCloseRequest = ::exitApplication,
menuBar = {
Menu("File") {
Item("New", shortcut = MenuShortcut.primary(Key.N)) { newDocument() }
Menu("Open Recent") {
recent.forEach { Item(it.name) { open(it) } }
}
Separator()
Item("Save", shortcut = MenuShortcut.primary(Key.S), enabled = dirty) { save() }
}
Menu("View") {
CheckboxItem("Word wrap", checked = wrap) { wrap = it }
Separator()
themes.forEach { RadioButtonItem(it, selected = it == theme) { theme = it } }
}
},
) { /* content */ }The DSL mirrors Compose's own MenuBar — Menu, Item, CheckboxItem, RadioButtonItem,
Separator, and Menu again for a submenu. Sliding along the bar switches between open menus, and
hovering an item opens its submenu, the way a desktop menu bar behaves.
Menus also take over where the title goes: with a menu bar in the row the title is centered on the
space they leave over and drawn a step back, whatever TitleBarStyle.titleAlignment says. A
start-aligned title — the Windows layout — would otherwise sit right against the last menu in the
same size and weight, and read as one more menu.
Shortcuts are MenuShortcut, not Compose's KeyShortcut, which keeps its key and modifiers
internal — there would be no way to draw ⌘N next to the item or to match a key event against it.
MenuShortcut.primary(Key.N) is Command on macOS and Ctrl everywhere else. They fire whether or not
their menu is open, including from inside a closed submenu, and a disabled item — or an item in a
disabled menu — does not fire at all.
The bar itself is AppMenuBar, public so it can go into a hand-rolled title bar too.
Some apps want the real thing — on macOS, the system menu bar at the top of the screen. AppFrame's
content is an AppFrameScope, which is both a ColumnScope and Compose's FrameWindowScope, so
MenuBar { … } works inside it as usual:
AppFrame(onCloseRequest = ::exitApplication) {
MenuBar {
Menu("File") { Item("New", onClick = ::newDocument) }
}
App(Modifier.weight(1f))
}TitleBarStyle.maximizeAction decides what the middle button and a double click do:
MaximizeAction.Maximize |
MaximizeAction.Fullscreen |
|
|---|---|---|
| Windows, Linux | WindowPlacement.Maximized |
WindowPlacement.Fullscreen |
| macOS | WindowPlacement.Maximized |
whole screen, via AWT — see below |
macOS is the awkward one. Its green button normally goes fullscreen, but that is a decorated
window's privilege: AppKit ignores toggleFullScreen: on the borderless window an undecorated
AppFrame is. Nothing throws, and WindowState.placement reports Fullscreen regardless, so a
title bar that trusts the placement redraws as if the window had resized — while the window sits
there at the same size. That is why TitleBarStyle.MacOs zooms instead: filling the work area is
what an option-click on the green button does, and it is real.
Asking for MaximizeAction.Fullscreen anyway still works — AppFrame hands the window to AWT's own
fullscreen, which covers the screen with the menu bar and Dock out of the way. There is no separate
Space and no slide animation; it is the only fullscreen a borderless window can have.
AppFrame(
onCloseRequest = ::exitApplication,
style = TitleBarStyle.MacOs.copy(maximizeAction = MaximizeAction.Fullscreen),
) { /* content */ }The title bar is covered by viddik screenshot tests — one
parameterized fixture in appframe/src/desktopTest/.../TitleBarScreenshots.kt produces a golden per
platform layout (Windows, Windows maximized, macOS focused/unfocused, GNOME, left-side Linux, and
Windows/macOS with menus), each in light and dark.
The wiring comes from viddik's Gradle plugin (id("io.github.youndie.viddik") in
appframe/build.gradle.kts), and verification is part of check — ./gradlew build runs it:
./gradlew :appframe:viddikRecord # (re-)record, then look at the PNGs
./gradlew :appframe:viddikVerify # verify all 18
./gradlew :appframe:viddikVerify --component "Linux GNOME" # just one layout, light and dark
./gradlew :appframe:viddikShowroom # browse the fixtures in a windowGoldens are recorded wherever you work, and committed. The fixtures build their theme with
viddik's viddikTypography(), which draws in a bundled Roboto instead of whatever font the host
happens to have — so the capture no longer depends on the machine it runs on. Recorded on macOS and
on ubuntu-latest and compared byte for byte, all eighteen PNGs come out identical, which is why
CI verifies on all three runners rather than on the one that recorded them.
This is what viddik's bundled font buys a project that has none of its own. A project that ships its own font should keep using it and run the bytes through
normalizeVerticalMetrics()instead — substituting Roboto into a golden of a UI that doesn't use Roboto is worse than useless.
./gradlew :app:runThe sample switches between the platform layouts at runtime, and its View menu flips the zoom button between maximizing and fullscreen.
A customizable window frame library for Compose Multiplatform desktop applications. Provides a modern, native-looking window with a Compose-drawn title bar and standard window controls.
org.gnome.desktop.wm.preferences button-layout) decides both the side and which buttons exist —
right on GNOME/KDE/XFCE (GNOME ships with Close only), left on elementary OS and the old Unity
layout. Falls back to the right-hand layout if the setting can't be read.WindowPlacement.Maximized — respects the taskbar/dock and multi-monitor
setups; on macOS the green button zooms, and MaximizeAction.Fullscreen covers the whole screen
even though AppKit will not give an undecorated window its native fullscreen (Zoom and
fullscreen)actions slot in the title barJMenuBar strip that ignores your theme (Menus)MaterialTheme
Canvas
Window blockThe library is on Maven Central, so no extra repository is needed:
desktopMain.dependencies {
implementation("io.github.youndie:appframe:0.2.0")
}Gradle resolves appframe to its desktop variant through the module metadata, in a multiplatform
module and in a plain kotlin("jvm") one alike. A build that reads only the POM — Maven, for
one — takes the variant directly: io.github.youndie:appframe-desktop:0.2.0.
Requirements: Java 21 (the published metadata says so, and Gradle refuses the dependency on an older toolchain rather than failing at class loading) and Compose Multiplatform 1.12, which the library is compiled against.
The 0.1.x builds were published to https://reposilite.kotlin.website/releases as
appframe-desktop only; that repository can be dropped when moving to 0.2.0.
Every push to main still goes there, as <next release>.<run> — 0.2.1.<run> is a build on the
way to 0.2.1, not a release. In Maven's ordering it sorts above 0.2.1 itself, so a build that
takes one is not offered the release as an upgrade: moving to Maven Central is a hand edit.
0.2.0.28–0.2.0.30 are builds of 0.2.0 and the two commits after it, published before the
head moved; nothing needs them.
fun main() = application {
MaterialTheme {
AppFrame(onCloseRequest = ::exitApplication, title = "My App") {
App(modifier = Modifier.fillMaxSize())
}
}
}AppFrame uses TitleBarStyle.forHost() by default. Any of the built-in styles — or a copy() of
one — can be passed in, which is handy for demos and screenshots:
AppFrame(
onCloseRequest = ::exitApplication,
style = TitleBarStyle.Windows, // .MacOs, .Linux, or forHost(HostOs.Linux)
actions = {
IconButton(onClick = ::openSettings) { /* … */ }
},
) { /* content */ }Every visual aspect is a field on TitleBarStyle: bar height, which side the controls sit on, their
order, appearance (Bars or TrafficLights), button and glyph size, spacing, title alignment,
window corner radius, and whether the middle button maximizes or goes fullscreen.
One caveat when swapping styles at runtime: cornerRadius > 0 makes AppFrame ask for a
transparent window, and transparency cannot change after a window is shown. Only the style used on
the first composition decides whether the window can be rounded; later styles animate the radius
inside that decision (and are ignored if the window started opaque).
The title bar can also be used on its own inside a hand-rolled Window via the TitleBar
composable, and WindowState.toggleMaximized() / isMaximized / isFullscreen are public helpers.
Provide LocalAppFrameWindow around it so its zoom button can reach the window.
menuBar draws Material menus in the title bar, laid out after the app icon — the arrangement of
every single-row title bar with menus, from VS Code to a GNOME header bar:
AppFrame(
onCloseRequest = ::exitApplication,
menuBar = {
Menu("File") {
Item("New", shortcut = MenuShortcut.primary(Key.N)) { newDocument() }
Menu("Open Recent") {
recent.forEach { Item(it.name) { open(it) } }
}
Separator()
Item("Save", shortcut = MenuShortcut.primary(Key.S), enabled = dirty) { save() }
}
Menu("View") {
CheckboxItem("Word wrap", checked = wrap) { wrap = it }
Separator()
themes.forEach { RadioButtonItem(it, selected = it == theme) { theme = it } }
}
},
) { /* content */ }The DSL mirrors Compose's own MenuBar — Menu, Item, CheckboxItem, RadioButtonItem,
Separator, and Menu again for a submenu. Sliding along the bar switches between open menus, and
hovering an item opens its submenu, the way a desktop menu bar behaves.
Menus also take over where the title goes: with a menu bar in the row the title is centered on the
space they leave over and drawn a step back, whatever TitleBarStyle.titleAlignment says. A
start-aligned title — the Windows layout — would otherwise sit right against the last menu in the
same size and weight, and read as one more menu.
Shortcuts are MenuShortcut, not Compose's KeyShortcut, which keeps its key and modifiers
internal — there would be no way to draw ⌘N next to the item or to match a key event against it.
MenuShortcut.primary(Key.N) is Command on macOS and Ctrl everywhere else. They fire whether or not
their menu is open, including from inside a closed submenu, and a disabled item — or an item in a
disabled menu — does not fire at all.
The bar itself is AppMenuBar, public so it can go into a hand-rolled title bar too.
Some apps want the real thing — on macOS, the system menu bar at the top of the screen. AppFrame's
content is an AppFrameScope, which is both a ColumnScope and Compose's FrameWindowScope, so
MenuBar { … } works inside it as usual:
AppFrame(onCloseRequest = ::exitApplication) {
MenuBar {
Menu("File") { Item("New", onClick = ::newDocument) }
}
App(Modifier.weight(1f))
}TitleBarStyle.maximizeAction decides what the middle button and a double click do:
MaximizeAction.Maximize |
MaximizeAction.Fullscreen |
|
|---|---|---|
| Windows, Linux | WindowPlacement.Maximized |
WindowPlacement.Fullscreen |
| macOS | WindowPlacement.Maximized |
whole screen, via AWT — see below |
macOS is the awkward one. Its green button normally goes fullscreen, but that is a decorated
window's privilege: AppKit ignores toggleFullScreen: on the borderless window an undecorated
AppFrame is. Nothing throws, and WindowState.placement reports Fullscreen regardless, so a
title bar that trusts the placement redraws as if the window had resized — while the window sits
there at the same size. That is why TitleBarStyle.MacOs zooms instead: filling the work area is
what an option-click on the green button does, and it is real.
Asking for MaximizeAction.Fullscreen anyway still works — AppFrame hands the window to AWT's own
fullscreen, which covers the screen with the menu bar and Dock out of the way. There is no separate
Space and no slide animation; it is the only fullscreen a borderless window can have.
AppFrame(
onCloseRequest = ::exitApplication,
style = TitleBarStyle.MacOs.copy(maximizeAction = MaximizeAction.Fullscreen),
) { /* content */ }The title bar is covered by viddik screenshot tests — one
parameterized fixture in appframe/src/desktopTest/.../TitleBarScreenshots.kt produces a golden per
platform layout (Windows, Windows maximized, macOS focused/unfocused, GNOME, left-side Linux, and
Windows/macOS with menus), each in light and dark.
The wiring comes from viddik's Gradle plugin (id("io.github.youndie.viddik") in
appframe/build.gradle.kts), and verification is part of check — ./gradlew build runs it:
./gradlew :appframe:viddikRecord # (re-)record, then look at the PNGs
./gradlew :appframe:viddikVerify # verify all 18
./gradlew :appframe:viddikVerify --component "Linux GNOME" # just one layout, light and dark
./gradlew :appframe:viddikShowroom # browse the fixtures in a windowGoldens are recorded wherever you work, and committed. The fixtures build their theme with
viddik's viddikTypography(), which draws in a bundled Roboto instead of whatever font the host
happens to have — so the capture no longer depends on the machine it runs on. Recorded on macOS and
on ubuntu-latest and compared byte for byte, all eighteen PNGs come out identical, which is why
CI verifies on all three runners rather than on the one that recorded them.
This is what viddik's bundled font buys a project that has none of its own. A project that ships its own font should keep using it and run the bytes through
normalizeVerticalMetrics()instead — substituting Roboto into a golden of a UI that doesn't use Roboto is worse than useless.
./gradlew :app:runThe sample switches between the platform layouts at runtime, and its View menu flips the zoom button between maximizing and fullscreen.