Desktop
Graphical applications arose in the early 80s as we moved from text-based terminals to more technically capable systems. This was part of the Personal Computer (PC) movement of that time, which aimed to put a “computer in every home”. Introduced in 1984, the Apple Macintosh introduced the first successful commercial graphical operating system; other vendors (e.g. Microsoft, Commodore, Sun) quickly followed suit with their own graphical operating systems. The conventions that were introduced on the Mac quickly became standard on other platforms.
Graphical User Interfaces (GUIs) were based on keyboard and mouse-input, and a graphical output (typically a CRT monitor). Such systems were seen as more “approachable” and “easy-to-use” for novice users, and were a major driver to the modern PC era.
{Desktop applications refers to graphical applications designed for a notebook or desktop computer, typically running Windows, macOS or Linux. Users interact with these applications using a mouse and keyboard, although other devices may also be supported (e.g. camera, trackpad).
Modern graphical desktop applications closely resemble their earlier counterparts, and still rely heavily on point-and-click interaction. Modern operating systems have mostly been reduced to Linux, macOS and Windows.
Design
There are obvious benefits to a graphical display being able to display rich colours, graphics and multimedia. However, point-and-click interfaces also provide other benefits:
- The interface provides affordances: visual suggestions on how you might interact with the system. This can include hints like tooltips, or a graphical design that makes use of controls obvious (e.g. handles to show where to “grab” a window corner).
- Systems provide continuous feedback to users. This includes obvious feedback (e.g. dialog boxes, status lines) and more subtle, continuous feedback (e.g. widgets animating when pressed).
- Interfaces are explorable: users can use menus, and cues to discover new features.
- Low cost of errors: undo-redo, and the ability to rollback to previous state makes exploration low-risk.
- These environments encouraged developers to use consistent widgets and interactive elements. Standardization led to a common look-and-feel, and placement of common controls - which made software easier to learn, especially for novices. Many of the standard features that we take for granted are a direct result of this design standardization in the 80s1.
Notice that Windows, macOS, Linux all share a very common interaction paradigm, and a common look-and-feel! You can move between operating systems and be quite comfortable because of this.
Project Setup
A desktop GUI project needs a GUI framework. We’ll use Compose Multiplatform for this purpose. We discuss Compose in great detail in the User Interface section. For now, we’ll just focus on getting a Compose project setup.
Create a new project
To create a Compose Multiplatform project, use the project wizard in IntelliJ IDEA, and select Compose for Desktop
as your project. Fill in the relevant details:
- Name: a unique name for your project
- Location: top-level directory; a new directory will be created here.
- Group: top-level package name for your code; should be unique; typically reverse-DNS name.
- Artifact: leave default
- JDK: the version of JDK to use.
Add Compose to a project
If you already have a working project, you can add the Compose libraries to it. Extending your build.gradle.kts
file to include the appropriate plugin and dependencies.
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins {
kotlin("jvm")
id("org.jetbrains.compose")
}
group = "com.example"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
google()
}
dependencies {
// Note, if you develop a library, you should use compose.desktop.common.
// compose.desktop.currentOs should be used in launcher-sourceSet
// (in a separate module for demo project and in testMain).
// With compose.desktop.common you will also lose @Preview functionality
implementation(compose.desktop.currentOs)
}
compose.desktop {
application {
mainClass = "MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "scratch-pad"
packageVersion = "1.0.0"
}
}
}
Compile and package
You can compile your project using gradlew
or the Gradle menu (View
> Tool Windows
> Gradle
).
Command | What does it do? |
---|---|
Tasks > build > clean | Removes temp files (deletes the /build directory) |
Tasks > build > build | Compiles your application |
Tasks > compose desktop > run | Executes your application (builds it first if necessary) |
Tasks > compose desktop > package | Create an installer for your platform! |
Architecture
Desktop applications have a main method (just like console applications).
Create a main method
Your entry point for a desktop application is the main
method. For a Compose application, you need to:
- use a
main
method as its entry point, - declare a top-level
application
scope, - declare one or more windows within that application scope.
Here is a simple application to display a window (in fact, it’s the simplest possible way to do this).
import androidx.compose.ui.window.singleWindowApplication
fun main() = singleWindowApplication(title = "Window Title", exitProcessOnExit = true) {
}
To make examples easier to read, we won’t include the import
statements. If you copy-paste any samples into IntelliJ, you will need to add the imports back so that it will compile. To do this, use the Show Context Actions
menu (ALT-ENTER) over any offending code:
Window management
The example above limits us to a single windows, which isn’t always desireable. Graphical desktop applications also need to suuport the following:
- Multiple application windows. Most applications will often present their interface within a single, interactive window, but it can sometimes be useful to have multiple simultaneous windows controlled by a single application2.
- Full-screen and windowed interaction: although graphical applications tend to run windowed, they should usable full-screen as well. The window contents should scale or reposition themselves as the window size changes.
- Window decorations: Each window should have a titlebar, minimize/maximize/restore buttons (that work as expected).
Photoshop, for instance, famously has multiple windows tiled over the desktop. It’s also a very, very complex program, so it needs to split up functionality like this.
A more flexible solution is to use an expanded syntax which allows us to create and manage multiple windows. Compose will create them with min/max/restore buttons based on the defaults for the existing operating system.
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() =
application {
Window(title = "Window Title", onCloseRequest = ::exitApplication) { }
}
application
and Window
are top-level composables.
Window
accepts a number of parameters, including title
and onCloseRequest
, which is an event handler that fires when the window closes (we’re calling the built-in exitApplication
method to quit when the window closes).
Features
Set window position and size
You can set the initial position and size of the window using the WindowState
parameter. Here’s an example of specifying a number of window characteristics.
fun main() = application {
Window(
title = "Simple Window",
onCloseRequest = ::exitApplication,
state = WindowState(
placement = WindowPlacement.Floating,
position = WindowPosition.PlatformDefault,
width = 300.dp,
height = 400.dp)
) {
Application()
}
}
@Composable
@Preview
fun Application() {
MaterialTheme {
// UI here
}
}
Create multiple windows
Using this expanded syntax, you can add multiple windows using the Window()
composable!
fun main() {
application{
Window(
title = "Window 1",
state = WindowState(
position = WindowPosition(0.dp, 0.dp),
size = DpSize(300.dp, 200.dp)),
onCloseRequest = ::exitApplication
)
{
Text("This is the first window")
}
Window(
title = "Window 2",
state = WindowState(
position = WindowPosition(50.dp, 50.dp),
size = DpSize(300.dp, 200.dp)),
onCloseRequest = ::exitApplication
)
{
Text("This is the second window")
}
}
}
Navigation between screens
When working with Android, you typically present the user with one screen at a time. If you have a different screen to present, you would navigate to that screen i.e., replace the current contents with a completely new screen. Android has navigation classes to help you manage that transition.
However, desktop applications are structured differently. In a desktop environment, if you want to present more information, you typically just open another window. Most of the time, that is recommended.
However, you may want to think about using screen navigation on desktop for feature parity, or to reuse user interface code across platforms e.g., building both Android and desktop from the same codebase.
Although the Android navigation classes have been ported to Compose Multiplatform, they’re currently listed as experimental
. If you really want to support this on desktop, I’d suggest looking at another library like Voyager. It’s a Kotlin multiplatform library, meaning that you could use the same code on Android, Desktop, iOS, Web…. anywhere that Kotlin executes.
Minimize to the system tray
fun main() = application {
var isVisible by remember { mutableStateOf(true) }
Window(
onCloseRequest = { isVisible = false },
state = WindowState(size = DpSize(250.dp, 150.dp)),
visible = isVisible,
title = "Counter",
) {
var counter by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
counter++
delay(1000)
}
}
Column(modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally)
{
Text(counter.toString())
}
}
if (!isVisible) {
Tray(
TrayIcon,
tooltip = "Counter",
onAction = { isVisible = true },
menu = {
Item("Exit", onClick = ::exitApplication)
},
)
}
}
object TrayIcon : Painter() {
override val intrinsicSize = Size(256f, 256f)
override fun DrawScope.onDraw() {
drawOval(Color(0xFFFFA500))
}
}
Keyboard and mouse input
We should be able to handle:
- Point-and-click interaction using a mouse.
- Keyboard input, leveraging OS support for different keyboards.
- Keyboard shortcuts, appropriate to the operating system 3. This is often combined with menubars and menu items. For example,
- Ctrl-N for File-New, Ctrl-O for File-Open, Ctrl-Q for Quit.
- Ctrl-X for Cut, Ctrl-C for Copy, Ctrl-V for Paste.
- F1 for Help.
Ctrl on Windows and Linux, CMD on Mac.
Handle keyboard Input
fun main() = application {
Window(
title = "Key Events",
state = WindowState(width = 500.dp, height = 100.dp),
onCloseRequest = ::exitApplication,
onKeyEvent = {
if (it.type == KeyEventType.KeyUp) {
println("Window handler: " + it.key.toString())
}
false
}
) {
MaterialTheme {
Frame()
}
}
}
@Composable
fun Frame() {
Row(modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
KeyboardButton("1")
KeyboardButton("2")
KeyboardButton("3")
}
}
@Composable
fun KeyboardButton(caption: String) {
Button(
onClick = { println("Button $caption click") },
modifier = Modifier.onKeyEvent {
if (it.type == KeyEventType.KeyUp) {
println("Button $caption handler: " + it.key.toString());
}
false
}
) {
Text("Button $caption")
}
}
Handle mouse input
Composables have handlers for onClick
(single-click), onDoubleClick
and onLongClick
(press). You can assign functions to each of these handlers, which are executed when the input is detected.
fun main() = singleWindowApplication (
title = "Mouse Events",
resizable = false,
state = WindowState(
position = WindowPosition(0.dp, 0.dp),
size = DpSize(500.dp, 250.dp)
)
)
{
var count by remember { mutableStateOf(0) }
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth()) {
var text by remember { mutableStateOf("Click magenta box!") }
Column(
modifier = Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceEvenly,
horizontalAlignment = Alignment.CenterHorizontally
) {
@OptIn(ExperimentalFoundationApi::class)
Box(
modifier = Modifier
.background(Color.Magenta)
.fillMaxWidth(0.9f)
.fillMaxHeight(0.2f)
.combinedClickable(
onClick = {
text = "Click! ${count++}"
},
onDoubleClick = {
text = "Double click! ${count++}"
},
onLongClick = {
text = "Long click! ${count++}"
}
)
)
Text(text = text,
modifier = Modifier.fillMaxWidth(0.9f),
fontSize = 24.sp)
Text("You can single-click, double-click or long-press the mouse button.",
modifier = Modifier.fillMaxWidth(0.9f),
fontSize = 24.sp
)
}
}
}
The following code demonstrates how to track mouse movement through x, y positions.
@OptIn(ExperimentalComposeUiApi::class)
fun main() = singleWindowApplication(
title = "Random window colour",
resizable = false
) {
var color by remember { mutableStateOf(Color(0, 0, 0)) }
Box(
modifier = Modifier
.wrapContentSize(Alignment.Center)
.fillMaxSize()
.background(color = color)
.onPointerEvent(PointerEventType.Move) {
val position = it.changes.first().position
color = Color(position.x.toInt() % 256, position.y.toInt() % 256, 0)
}
)
}
Creating menus
fun main() = application {
Window(onCloseRequest = ::exitApplication) {
App(this, this@application)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun App(
windowScope: FrameWindowScope,
applicationScope: ApplicationScope
) {
windowScope.MenuBar {
Menu("File", mnemonic = 'F') {
val nextWindowState = rememberWindowState()
Item(
"Exit",
onClick = { applicationScope.exitApplication() },
shortcut = KeyShortcut(
Key.X, ctrl = false
)
)
}
}
}
Setting the icon
To change the application icon, you need to include an icon image file in your project resources, and then tell to Compose Desktop to use it when building installer images. For example:
compose.desktop {
application {
mainClass = "MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "ApplicationName"
packageVersion = "1.0.0"
macOS {
iconFile.set(project.file("src/main/resources/logo.icns"))
}
windows {
iconFile.set(project.file("src/main/resources/logo.ico"))
}
linux {
iconFile.set(project.file("src/main/resources/logo.png"))
}
}
}
}
Drawing on a canvas
We can draw primitive graphics on a canvas.
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
state = WindowState(width = 250.dp, height = 300.dp),
title = "Drawing Canvas",
resizable = true
) {
Surface(color = Color.White) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = Color.Yellow,
radius = 125.0f,
center = Offset(250.0f, 250.0f)
)
drawRect(
color = Color.Blue,
style = Fill,
size = Size(150.0f, 150.0f),
topLeft = Offset(100.0f, 100.0f)
)
}
}
}
}
Show a dialog
We can show standard OS dialogs using the Dialog composable.
fun main() = application {
var isOpen by remember { mutableStateOf(true) }
var isAskingToClose by remember { mutableStateOf(false) }
if (isOpen) {
Window(
onCloseRequest = { isAskingToClose = true }
) {
if (isAskingToClose) {
Dialog(
onCloseRequest = { isAskingToClose = false },
title = "Close the document without saving?",
) {
Button(
onClick = { isOpen = false }
) {
Text("Yes")
}
}
}
}
}
}
Packaging & Installers
Distributing a GUI application is complex; you need to generate a platform specific executable, and install dependencies in specific locations. For that reason, you need a more complex installer than what we might use for a command-line application.
!!! info We use the term installer to refer to software that installs other software. For example, if you purchase and download a Windows application, it will typically be delivered as an MSI file; you execute that, and it will install the actual application in the appropriate location (after prompting you for installation location, showing license terms etc). !!!
Luckily, Compose Multiplatform includes tasks for generating platform-specific installers.
In Gradle:
Tasks
> compose desktop
> packageDistributionForCurrentOS
This task will produce an installer for the platform where you are executing it e.g., a PKG or DMG file on macOS, or an MSI installer on Windows.