diff --git a/build.gradle.kts b/build.gradle.kts index 6420b04..51e1bbe 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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" diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 846b529..be95ddb 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -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 diff --git a/src/main/kotlin/ui/AppTray.kt b/src/main/kotlin/ui/AppTray.kt new file mode 100644 index 0000000..2508053 --- /dev/null +++ b/src/main/kotlin/ui/AppTray.kt @@ -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)) + } +} diff --git a/src/main/kotlin/ui/ChannelScreen.kt b/src/main/kotlin/ui/ChannelScreen.kt index 94146e8..c2959bd 100644 --- a/src/main/kotlin/ui/ChannelScreen.kt +++ b/src/main/kotlin/ui/ChannelScreen.kt @@ -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) diff --git a/src/main/kotlin/ui/ChannelWindow.kt b/src/main/kotlin/ui/ChannelWindow.kt index 3a9d95c..5588b27 100644 --- a/src/main/kotlin/ui/ChannelWindow.kt +++ b/src/main/kotlin/ui/ChannelWindow.kt @@ -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,