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"
|
||||
version = "0.1.13"
|
||||
version = "0.1.14"
|
||||
|
||||
// No project-level repositories{} block here: settings.gradle.kts sets
|
||||
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
|
||||
@@ -22,6 +22,8 @@ dependencies {
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1")
|
||||
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 {
|
||||
@@ -38,7 +40,7 @@ compose.desktop {
|
||||
appResourcesRootDir.set(project.file("installerResources"))
|
||||
targetFormats(TargetFormat.Msi, TargetFormat.Exe)
|
||||
packageName = "wIRC"
|
||||
packageVersion = "0.1.13"
|
||||
packageVersion = "0.1.14"
|
||||
windows {
|
||||
menuGroup = "wIRC"
|
||||
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
|
||||
|
||||
+26
-7
@@ -23,6 +23,7 @@ import data.storageId
|
||||
import irc.ConnectionManager
|
||||
import ui.ChannelWindow
|
||||
import ui.MainScreen
|
||||
import ui.TrayWindowBehavior
|
||||
import ui.rememberPersistentWindowState
|
||||
import java.io.File
|
||||
|
||||
@@ -91,18 +92,34 @@ fun main() = application {
|
||||
val mainLineGap = ui.rememberLineGap()
|
||||
ui.WindowSnapping(mainWindowState, mainLineGap, osFrame = true)
|
||||
|
||||
// There's no persistent tray/notification surface on this build, so closing the main
|
||||
// server-list window quits the whole app (and every channel window with it) - the
|
||||
// same "closing the primary window ends the session" convention most desktop chat
|
||||
// apps without a tray icon follow.
|
||||
// wIRC lives in the tray: no window is on the taskbar, and minimizing any window hides
|
||||
// all of them (see TrayWindowBehavior). Hidden windows stay composed and connected, so
|
||||
// nothing is lost while they're away; showing them again puts each back where it was.
|
||||
// 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(
|
||||
onCloseRequest = ::exitApplication,
|
||||
// The unread count shows in the taskbar too, so new server messages are visible
|
||||
// even when the main window is behind other windows.
|
||||
title = if (mainUnread > 0) "wIRC - Main ($mainUnread new)" else "wIRC - Main",
|
||||
visible = windowsShown,
|
||||
title = mainTitle,
|
||||
icon = painterResource("icon.png"),
|
||||
state = mainWindowState
|
||||
) {
|
||||
TrayWindowBehavior(mainWindowState, hideAll)
|
||||
androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) {
|
||||
MaterialTheme(colorScheme = neutralDarkScheme) {
|
||||
Surface {
|
||||
@@ -128,6 +145,8 @@ fun main() = application {
|
||||
bufferKey = bufferKey,
|
||||
prefsRepo = prefsRepo,
|
||||
colorScheme = neutralDarkScheme,
|
||||
visible = windowsShown,
|
||||
onHideAll = hideAll,
|
||||
onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey },
|
||||
onCloseSelf = { openBuffers = openBuffers - bufferKey },
|
||||
// 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.window.DialogWindow
|
||||
import androidx.compose.ui.window.WindowScope
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import androidx.compose.ui.window.rememberDialogState
|
||||
import data.ChannelBuffer
|
||||
import data.ChannelUser
|
||||
@@ -184,7 +183,7 @@ private fun copyToClipboardSafely(scope: CoroutineScope, text: String) {
|
||||
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun WindowScope.ChannelScreen(
|
||||
windowState: WindowState,
|
||||
onMinimize: () -> Unit,
|
||||
onCloseWindow: () -> Unit,
|
||||
channelName: String,
|
||||
buffer: ChannelBuffer?,
|
||||
@@ -383,7 +382,7 @@ fun WindowScope.ChannelScreen(
|
||||
FadingTitleBarButton(onClick = { showSettings = !showSettings }) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Window settings", tint = textColor, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
FadingTitleBarButton(onClick = { windowState.isMinimized = true }) {
|
||||
FadingTitleBarButton(onClick = onMinimize) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 10.dp, height = 2.dp)
|
||||
|
||||
@@ -46,6 +46,8 @@ fun ChannelWindow(
|
||||
bufferKey: BufferKey,
|
||||
prefsRepo: WindowPrefsRepository,
|
||||
colorScheme: ColorScheme,
|
||||
visible: Boolean,
|
||||
onHideAll: () -> Unit,
|
||||
onOpenBuffer: (BufferKey) -> Unit,
|
||||
onCloseSelf: () -> 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
|
||||
// OS chrome would otherwise provide. No Surface wrapper here (M3 Surface paints an
|
||||
// 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(
|
||||
onCloseRequest = onCloseSelf,
|
||||
visible = visible,
|
||||
title = windowTitle,
|
||||
icon = painterResource("icon.png"),
|
||||
state = windowState,
|
||||
@@ -93,10 +98,11 @@ fun ChannelWindow(
|
||||
transparent = true,
|
||||
resizable = true
|
||||
) {
|
||||
TrayWindowBehavior(windowState, onHideAll)
|
||||
CompositionLocalProvider(LocalLineGap provides lineGap) {
|
||||
MaterialTheme(colorScheme = colorScheme) {
|
||||
ChannelScreen(
|
||||
windowState = windowState,
|
||||
onMinimize = onHideAll,
|
||||
onCloseWindow = onCloseSelf,
|
||||
channelName = windowTitle,
|
||||
buffer = buffer,
|
||||
|
||||
Reference in New Issue
Block a user