Live in the system tray instead of the taskbar, bump to 0.1.14

- A tray icon (the app icon) is there for as long as wIRC runs. Left click hides
  or shows every wIRC window; right click has Show wIRC, Hide wIRC and Exit. Exit
  disconnects every server and quits.
- No wIRC window has a taskbar button. Each window is given a hidden owner window
  through JNA, because Window.Type.UTILITY can't be set after Compose builds the
  window and would take wIRC main's minimize button away.
- Minimizing any window hides all of them to the tray. Showing them again puts
  each one back where it was. Hidden windows stay connected and keep filling their
  buffers. Closing a window with X works the same as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
weishaupt
2026-10-11 09:14:43 -04:00
co-authored by Claude Opus 5.5
parent 4ac7b75962
commit 24f54574ec
5 changed files with 165 additions and 13 deletions
+4 -2
View File
@@ -6,7 +6,7 @@ plugins {
} }
group = "com.tpuahsiew.wirc" group = "com.tpuahsiew.wirc"
version = "0.1.13" version = "0.1.14"
// No project-level repositories{} block here: settings.gradle.kts sets // No project-level repositories{} block here: settings.gradle.kts sets
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already // dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
@@ -22,6 +22,8 @@ dependencies {
implementation(compose.materialIconsExtended) implementation(compose.materialIconsExtended)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1")
implementation("org.json:json:20240303") implementation("org.json:json:20240303")
// Native Windows calls that keep wIRC's windows off the taskbar (see AppTray.kt)
implementation("net.java.dev.jna:jna-platform:5.14.0")
} }
kotlin { kotlin {
@@ -38,7 +40,7 @@ compose.desktop {
appResourcesRootDir.set(project.file("installerResources")) appResourcesRootDir.set(project.file("installerResources"))
targetFormats(TargetFormat.Msi, TargetFormat.Exe) targetFormats(TargetFormat.Msi, TargetFormat.Exe)
packageName = "wIRC" packageName = "wIRC"
packageVersion = "0.1.13" packageVersion = "0.1.14"
windows { windows {
menuGroup = "wIRC" menuGroup = "wIRC"
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a" upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
+26 -7
View File
@@ -23,6 +23,7 @@ import data.storageId
import irc.ConnectionManager import irc.ConnectionManager
import ui.ChannelWindow import ui.ChannelWindow
import ui.MainScreen import ui.MainScreen
import ui.TrayWindowBehavior
import ui.rememberPersistentWindowState import ui.rememberPersistentWindowState
import java.io.File import java.io.File
@@ -91,18 +92,34 @@ fun main() = application {
val mainLineGap = ui.rememberLineGap() val mainLineGap = ui.rememberLineGap()
ui.WindowSnapping(mainWindowState, mainLineGap, osFrame = true) ui.WindowSnapping(mainWindowState, mainLineGap, osFrame = true)
// There's no persistent tray/notification surface on this build, so closing the main // wIRC lives in the tray: no window is on the taskbar, and minimizing any window hides
// server-list window quits the whole app (and every channel window with it) - the // all of them (see TrayWindowBehavior). Hidden windows stay composed and connected, so
// same "closing the primary window ends the session" convention most desktop chat // nothing is lost while they're away; showing them again puts each back where it was.
// apps without a tray icon follow. // A window opened while hidden (e.g. a new PM) stays hidden until they're shown.
var windowsShown by remember { mutableStateOf(true) }
val hideAll: () -> Unit = { windowsShown = false }
val mainTitle = if (mainUnread > 0) "wIRC - Main ($mainUnread new)" else "wIRC - Main"
ui.AppTray(
// The unread count shows on the tray icon's tooltip too, so new server messages
// are visible even when the windows are hidden.
tooltip = if (mainUnread > 0) "wIRC ($mainUnread new)" else "wIRC",
onToggle = { windowsShown = !windowsShown },
onShow = { windowsShown = true },
onHide = hideAll,
onExit = shutdownApp
)
// Closing the main server-list window quits the whole app (and every channel window
// with it) - the tray's Exit does the same, and also disconnects first.
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
// The unread count shows in the taskbar too, so new server messages are visible visible = windowsShown,
// even when the main window is behind other windows. title = mainTitle,
title = if (mainUnread > 0) "wIRC - Main ($mainUnread new)" else "wIRC - Main",
icon = painterResource("icon.png"), icon = painterResource("icon.png"),
state = mainWindowState state = mainWindowState
) { ) {
TrayWindowBehavior(mainWindowState, hideAll)
androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) { androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) {
MaterialTheme(colorScheme = neutralDarkScheme) { MaterialTheme(colorScheme = neutralDarkScheme) {
Surface { Surface {
@@ -128,6 +145,8 @@ fun main() = application {
bufferKey = bufferKey, bufferKey = bufferKey,
prefsRepo = prefsRepo, prefsRepo = prefsRepo,
colorScheme = neutralDarkScheme, colorScheme = neutralDarkScheme,
visible = windowsShown,
onHideAll = hideAll,
onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey }, onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey },
onCloseSelf = { openBuffers = openBuffers - bufferKey }, onCloseSelf = { openBuffers = openBuffers - bufferKey },
// openBuffers is read when /quit runs, so every window open at that moment // openBuffers is read when /quit runs, so every window open at that moment
+126
View File
@@ -0,0 +1,126 @@
package ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.awt.ComposeWindow
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.WindowState
import com.sun.jna.Native
import com.sun.jna.Pointer
import com.sun.jna.platform.win32.User32
import com.sun.jna.platform.win32.WinDef
import com.sun.jna.platform.win32.WinUser
import java.awt.Frame
import java.awt.MenuItem
import java.awt.PopupMenu
import java.awt.SystemTray
import java.awt.TrayIcon
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.imageio.ImageIO
/**
* wIRC's icon in the Windows notification area, there for as long as the app runs. wIRC
* windows never show on the taskbar (see [hideFromTaskbar]), so this is how they come back
* after being hidden. Left click toggles all windows; right click opens Show / Hide / Exit.
* Plain AWT instead of Compose's Tray, because Compose's Tray only reports a double click.
*/
@Composable
fun AppTray(
tooltip: String,
onToggle: () -> Unit,
onShow: () -> Unit,
onHide: () -> Unit,
onExit: () -> Unit
) {
val currentToggle by rememberUpdatedState(onToggle)
val currentShow by rememberUpdatedState(onShow)
val currentHide by rememberUpdatedState(onHide)
val currentExit by rememberUpdatedState(onExit)
val trayIcon = remember {
if (!SystemTray.isSupported()) return@remember null
val image = Thread.currentThread().contextClassLoader.getResourceAsStream("icon.png")?.use { ImageIO.read(it) }
?: return@remember null
val menu = PopupMenu().apply {
add(MenuItem("Show wIRC").apply { addActionListener { currentShow() } })
add(MenuItem("Hide wIRC").apply { addActionListener { currentHide() } })
addSeparator()
add(MenuItem("Exit").apply { addActionListener { currentExit() } })
}
TrayIcon(image, tooltip, menu).apply {
isImageAutoSize = true
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (e.button == MouseEvent.BUTTON1) currentToggle()
}
})
}
}
DisposableEffect(trayIcon) {
trayIcon?.let { runCatching { SystemTray.getSystemTray().add(it) } }
onDispose { trayIcon?.let { SystemTray.getSystemTray().remove(it) } }
}
LaunchedEffect(trayIcon, tooltip) { trayIcon?.toolTip = tooltip }
}
/**
* Called first thing inside every wIRC Window's content:
* - Keeps the window off the taskbar (see [hideFromTaskbar]).
* - Turns a minimize (the title-bar button, Win+D, etc.) into hiding every wIRC window to
* the tray: with no taskbar button, a minimized window would have no way back.
*/
@Composable
fun FrameWindowScope.TrayWindowBehavior(state: WindowState, onHideAll: () -> Unit) {
remember(window) { hideFromTaskbar(window) }
val currentHideAll by rememberUpdatedState(onHideAll)
LaunchedEffect(state) {
snapshotFlow { state.isMinimized }.collect { minimized ->
if (minimized) {
currentHideAll()
// Hidden before it's un-minimized, so it doesn't flash back up on the way
window.isVisible = false
state.isMinimized = false
}
}
}
}
/**
* Windows gives a taskbar button only to windows that have no owner, so every wIRC window is
* given one: a window that's never shown. Done natively because Window.Type.UTILITY (AWT's
* own way off the taskbar) can only be set before Compose has built the window, and on a
* framed window like wIRC main it also swaps in a small tool-window title bar with no
* minimize button. Runs while the window is built but not yet shown, which is when the
* taskbar decides whether it gets a button.
*/
fun hideFromTaskbar(window: ComposeWindow) {
try {
val hwnd = WinDef.HWND(Pointer(window.windowHandle))
val user32 = User32.INSTANCE
user32.SetWindowLongPtr(hwnd, GWLP_HWNDPARENT, TaskbarOwner.hwnd.pointer)
val exStyle = user32.GetWindowLong(hwnd, WinUser.GWL_EXSTYLE)
if (exStyle and WS_EX_APPWINDOW != 0) {
user32.SetWindowLong(hwnd, WinUser.GWL_EXSTYLE, exStyle and WS_EX_APPWINDOW.inv())
}
} catch (e: Throwable) {
// Not on Windows, or no native access: the window just keeps its taskbar button
}
}
private const val GWLP_HWNDPARENT = -8
private const val WS_EX_APPWINDOW = 0x00040000
private object TaskbarOwner {
val hwnd: WinDef.HWND by lazy {
// Made displayable (so it has a native window) but never shown
val frame = Frame().apply { addNotify() }
WinDef.HWND(Native.getComponentPointer(frame))
}
}
+2 -3
View File
@@ -79,7 +79,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogWindow import androidx.compose.ui.window.DialogWindow
import androidx.compose.ui.window.WindowScope import androidx.compose.ui.window.WindowScope
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.rememberDialogState import androidx.compose.ui.window.rememberDialogState
import data.ChannelBuffer import data.ChannelBuffer
import data.ChannelUser import data.ChannelUser
@@ -184,7 +183,7 @@ private fun copyToClipboardSafely(scope: CoroutineScope, text: String) {
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable @Composable
fun WindowScope.ChannelScreen( fun WindowScope.ChannelScreen(
windowState: WindowState, onMinimize: () -> Unit,
onCloseWindow: () -> Unit, onCloseWindow: () -> Unit,
channelName: String, channelName: String,
buffer: ChannelBuffer?, buffer: ChannelBuffer?,
@@ -383,7 +382,7 @@ fun WindowScope.ChannelScreen(
FadingTitleBarButton(onClick = { showSettings = !showSettings }) { FadingTitleBarButton(onClick = { showSettings = !showSettings }) {
Icon(Icons.Default.Settings, contentDescription = "Window settings", tint = textColor, modifier = Modifier.size(16.dp)) Icon(Icons.Default.Settings, contentDescription = "Window settings", tint = textColor, modifier = Modifier.size(16.dp))
} }
FadingTitleBarButton(onClick = { windowState.isMinimized = true }) { FadingTitleBarButton(onClick = onMinimize) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(width = 10.dp, height = 2.dp) .size(width = 10.dp, height = 2.dp)
+7 -1
View File
@@ -46,6 +46,8 @@ fun ChannelWindow(
bufferKey: BufferKey, bufferKey: BufferKey,
prefsRepo: WindowPrefsRepository, prefsRepo: WindowPrefsRepository,
colorScheme: ColorScheme, colorScheme: ColorScheme,
visible: Boolean,
onHideAll: () -> Unit,
onOpenBuffer: (BufferKey) -> Unit, onOpenBuffer: (BufferKey) -> Unit,
onCloseSelf: () -> Unit, onCloseSelf: () -> Unit,
onDisconnectServer: () -> Unit onDisconnectServer: () -> Unit
@@ -84,8 +86,11 @@ fun ChannelWindow(
// supplies its own title bar with minimize/close/drag/resize to make up for what the // supplies its own title bar with minimize/close/drag/resize to make up for what the
// OS chrome would otherwise provide. No Surface wrapper here (M3 Surface paints an // OS chrome would otherwise provide. No Surface wrapper here (M3 Surface paints an
// opaque background) - ChannelScreen already paints its own alpha-aware background. // opaque background) - ChannelScreen already paints its own alpha-aware background.
// Hidden to the tray along with every other wIRC window when [visible] is false - the
// window and its composition stay alive, so its buffer keeps filling while hidden.
Window( Window(
onCloseRequest = onCloseSelf, onCloseRequest = onCloseSelf,
visible = visible,
title = windowTitle, title = windowTitle,
icon = painterResource("icon.png"), icon = painterResource("icon.png"),
state = windowState, state = windowState,
@@ -93,10 +98,11 @@ fun ChannelWindow(
transparent = true, transparent = true,
resizable = true resizable = true
) { ) {
TrayWindowBehavior(windowState, onHideAll)
CompositionLocalProvider(LocalLineGap provides lineGap) { CompositionLocalProvider(LocalLineGap provides lineGap) {
MaterialTheme(colorScheme = colorScheme) { MaterialTheme(colorScheme = colorScheme) {
ChannelScreen( ChannelScreen(
windowState = windowState, onMinimize = onHideAll,
onCloseWindow = onCloseSelf, onCloseWindow = onCloseSelf,
channelName = windowTitle, channelName = windowTitle,
buffer = buffer, buffer = buffer,