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:
co-authored by
Claude Opus 5.5
parent
4ac7b75962
commit
24f54574ec
+4
-2
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user