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.

Early Mac desktop

{

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.

Windows 11

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.
1

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.

Create a new Compose project in IntelliJ IDEA

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).

CommandWhat does it do?
Tasks > build > cleanRemoves temp files (deletes the /build directory)
Tasks > build > buildCompiles your application
Tasks > compose desktop > runExecutes your application (builds it first if necessary)
Tasks > compose desktop > packageCreate an installer for your platform!

macOS installer

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) {
}

Empty application window

Warning

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:

Adding imports

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).
2

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")
        }
    }
}

Windows

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.

Tip

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))
    }
}

Counter window

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.
3

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")
    }
}

Handling keyboard input

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
                )
        }
    }
}

mouse-events

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)
            }
    )
}

Mouse move

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
                )
            )
        }
    }
}

menus

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)
                )
            }
        }
    }
}

Drawing canvas

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.

Warning

You can only generate installers for your current platform i.e., you need a Windows system to generate a Windows MSI installer.