2 Commits
Author SHA1 Message Date
weishauptandClaude Opus 5.5 78bd235a48 Minimize hides only that window, Show wIRC main tray item, remember hidden main, bump to 0.1.15
- Minimizing a window hides only that window to the tray. The other windows stay
  on the desktop. Win+D still hides everything.
- Tray left click shows every hidden window at its old position, or hides them
  all if none are hidden.
- The tray menu has a new Show wIRC main item that brings back just wIRC main.
- If wIRC main was hidden when wIRC quit, it starts hidden next time and the
  servers that were connected reconnect by themselves, so their channel windows
  open without wIRC main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:31:29 -04:00
weishauptandClaude Opus 5.5 24f54574ec 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>
2026-10-11 09:14:43 -04:00
7 changed files with 292 additions and 35 deletions
+4 -2
View File
@@ -6,7 +6,7 @@ plugins {
}
group = "com.tpuahsiew.wirc"
version = "0.1.13"
version = "0.1.15"
// 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.15"
windows {
menuGroup = "wIRC"
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
+54 -9
View File
@@ -1,6 +1,7 @@
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -18,11 +19,14 @@ import data.AliasRepository
import data.BufferKey
import data.MAIN_BUFFER_KEY
import data.ServerRepository
import data.SessionState
import data.SessionStateRepository
import data.WindowPrefsRepository
import data.storageId
import irc.ConnectionManager
import ui.ChannelWindow
import ui.MainScreen
import ui.TrayWindowBehavior
import ui.rememberPersistentWindowState
import java.io.File
@@ -60,10 +64,34 @@ fun main() = application {
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
var mainUnread by remember { mutableStateOf(0) }
// wIRC lives in the tray: no window is on the taskbar, and minimizing a window hides just
// that window (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.
// wIRC main starts hidden if it was hidden when wIRC last quit.
val sessionRepo = remember { SessionStateRepository(appRoot) }
val startSession = remember { sessionRepo.load() }
var mainHidden by remember { mutableStateOf(startSession.mainHidden) }
var hiddenBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
val anyHidden = mainHidden || openBuffers.any { it in hiddenBuffers }
val showAll: () -> Unit = { mainHidden = false; hiddenBuffers = emptySet() }
// Windows opened after this (e.g. a new PM) still show up
val hideAll: () -> Unit = { mainHidden = true; hiddenBuffers = openBuffers }
val saveSession: () -> Unit = {
sessionRepo.saveOnce(SessionState(mainHidden, ConnectionManager.connectedIds.value))
}
// Also saves when Windows ends the process (sign-out, shutdown) instead of the user quitting
DisposableEffect(Unit) {
val hook = Thread { saveSession() }
Runtime.getRuntime().addShutdownHook(hook)
onDispose { }
}
// Full shutdown for the "Exit wIRC" button: disconnect every server (which also writes
// all scrollback to disk), then end the process. A bare /quit in a channel window only
// disconnects that window's server - see ConnectionManager.disconnect.
val shutdownApp: () -> Unit = {
saveSession()
ConnectionManager.disconnectAll()
exitApplication()
}
@@ -91,18 +119,29 @@ 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.
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 = { if (anyHidden) showAll() else hideAll() },
onShow = showAll,
onShowMain = { mainHidden = false },
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",
onCloseRequest = { saveSession(); exitApplication() },
visible = !mainHidden,
title = mainTitle,
icon = painterResource("icon.png"),
state = mainWindowState
) {
TrayWindowBehavior(mainWindowState) { mainHidden = true }
androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) {
MaterialTheme(colorScheme = neutralDarkScheme) {
Surface {
@@ -110,6 +149,9 @@ fun main() = application {
repo = repo,
colorScheme = neutralDarkScheme,
displaySettings = mainDisplaySettings,
// Started hidden: reconnect what was connected at quit, so the channel
// windows open without wIRC main having to be shown to click Connect.
autoConnectServerIds = if (startSession.mainHidden) startSession.connectedServerIds else emptySet(),
onOpenChannel = { serverId, channelName ->
openBuffers = openBuffers + BufferKey(serverId, channelName)
},
@@ -128,8 +170,11 @@ fun main() = application {
bufferKey = bufferKey,
prefsRepo = prefsRepo,
colorScheme = neutralDarkScheme,
visible = bufferKey !in hiddenBuffers,
onHide = { hiddenBuffers = hiddenBuffers + bufferKey },
onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey },
onCloseSelf = { openBuffers = openBuffers - bufferKey },
// Also forgets it was hidden, so reopening it later shows it
onCloseSelf = { openBuffers = openBuffers - bufferKey; hiddenBuffers = hiddenBuffers - bufferKey },
// openBuffers is read when /quit runs, so every window open at that moment
// gets its "Disconnected" line, even one that hasn't had a line yet.
onDisconnectServer = {
@@ -0,0 +1,66 @@
package data
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
/**
* What wIRC looked like when it last quit: whether wIRC main was hidden to the tray, and
* which servers were connected. If main was hidden, the next start keeps it hidden and
* reconnects those servers so their channel windows open by themselves (see Main.kt).
*/
data class SessionState(
val mainHidden: Boolean = false,
val connectedServerIds: Set<String> = emptySet()
)
/** rootDir/session_state.json, read once at startup and written once at quit. */
class SessionStateRepository(rootDir: File) {
private val file = File(rootDir, "session_state.json")
@Volatile private var saved = false
fun load(): SessionState = try {
if (!file.exists()) SessionState() else {
val obj = JSONObject(file.readText())
val ids = obj.optJSONArray("connectedServerIds") ?: JSONArray()
SessionState(
mainHidden = obj.optBoolean("mainHidden", false),
connectedServerIds = (0 until ids.length()).map { ids.getString(it) }.toSet()
)
}
} catch (e: Exception) {
SessionState()
}
/**
* Only the first call counts: quitting can reach this from more than one place (the
* Exit/close handlers and the JVM shutdown hook), and a later call could come after the
* servers were already disconnected.
*/
@Synchronized
fun saveOnce(state: SessionState) {
if (saved) return
saved = true
try {
val obj = JSONObject().apply {
put("mainHidden", state.mainHidden)
put("connectedServerIds", JSONArray(state.connectedServerIds.toList()))
}
file.parentFile?.mkdirs()
// Temp file and move, same as WindowPrefsRepository, so a crash can't truncate it
val tmp = File(file.parentFile, "${file.name}.tmp")
tmp.writeText(obj.toString())
try {
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
} catch (e: AtomicMoveNotSupportedException) {
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
} catch (e: Exception) {
// Next start just opens normally
}
}
}
+131
View File
@@ -0,0 +1,131 @@
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 shows every hidden window, or hides them all if none are
* hidden; right click opens Show wIRC / Show wIRC main / Hide wIRC / 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,
onShowMain: () -> Unit,
onHide: () -> Unit,
onExit: () -> Unit
) {
val currentToggle by rememberUpdatedState(onToggle)
val currentShow by rememberUpdatedState(onShow)
val currentShowMain by rememberUpdatedState(onShowMain)
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("Show wIRC main").apply { addActionListener { currentShowMain() } })
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 of this window into hiding just this window to the tray ([onHide]):
* with no taskbar button, a minimized window would have no way back. Win+D minimizes
* every window, so it still hides them all.
*/
@Composable
fun FrameWindowScope.TrayWindowBehavior(state: WindowState, onHide: () -> Unit) {
remember(window) { hideFromTaskbar(window) }
val currentHide by rememberUpdatedState(onHide)
LaunchedEffect(state) {
snapshotFlow { state.isMinimized }.collect { minimized ->
if (minimized) {
currentHide()
// 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.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)
+7 -1
View File
@@ -46,6 +46,8 @@ fun ChannelWindow(
bufferKey: BufferKey,
prefsRepo: WindowPrefsRepository,
colorScheme: ColorScheme,
visible: Boolean,
onHide: () -> 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 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, onHide)
CompositionLocalProvider(LocalLineGap provides lineGap) {
MaterialTheme(colorScheme = colorScheme) {
ChannelScreen(
windowState = windowState,
onMinimize = onHide,
onCloseWindow = onCloseSelf,
channelName = windowTitle,
buffer = buffer,
+28 -20
View File
@@ -41,6 +41,7 @@ fun MainScreen(
repo: ServerRepository,
colorScheme: ColorScheme,
displaySettings: WindowDisplaySettings,
autoConnectServerIds: Set<String>,
onOpenChannel: (serverId: String, channelName: String) -> Unit,
onExitApp: () -> Unit,
onUnreadChange: (Int) -> Unit,
@@ -57,13 +58,38 @@ fun MainScreen(
var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
val coroutineScope = rememberCoroutineScope()
fun connectServer(server: ServerConfig) {
// Also rejoins the channels this server was in before a /quit - their
// windows are still open, and opening one that already is is a no-op.
ConnectionManager.connect(server)
if (server.autoJoinChannels.isNotEmpty()) {
// Auto-join sends JOIN correctly on connect (see IrcConnection),
// but nothing else opens a window for it like the manual
// "Join + Open" flow does - so give the server a moment to
// actually join us before opening windows for them.
coroutineScope.launch {
delay(500)
server.autoJoinChannels.forEach { ch ->
val current = openedChannels[server.id] ?: emptyList()
if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch))
onOpenChannel(server.id, ch)
}
}
}
}
// Servers to connect at startup, as if Connect had been clicked (see Main.kt)
LaunchedEffect(Unit) {
servers.filter { it.id in autoConnectServerIds }.forEach { connectServer(it) }
}
val buffers by ConnectionManager.buffers.collectAsState()
val mainMessages by remember { derivedStateOf { buffers[MAIN_BUFFER_KEY]?.messages ?: emptyList() } }
val textColor = mircPalette[displaySettings.textColorMircIndex]
// Unread indicator: lines that arrived in wIRC main while this window didn't have focus
// (e.g. while typing in a channel window). Shown next to the "Server messages" label and,
// via onUnreadChange, in the window title / taskbar. Cleared as soon as the window is
// via onUnreadChange, in the window title and the tray tooltip. Cleared as soon as the window is
// focused. Compared by timestamp rather than count so history loaded at startup never
// counts as new.
val windowFocused = LocalWindowInfo.current.isWindowFocused
@@ -116,25 +142,7 @@ fun MainScreen(
channelInput = channelInputs[server.id] ?: "#",
openedChannels = openedChannels[server.id] ?: emptyList(),
onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) },
onConnect = {
// Also rejoins the channels this server was in before a /quit - their
// windows are still open, and opening one that already is is a no-op.
ConnectionManager.connect(server)
if (server.autoJoinChannels.isNotEmpty()) {
// Auto-join sends JOIN correctly on connect (see IrcConnection),
// but nothing else opens a window for it like the manual
// "Join + Open" flow does - so give the server a moment to
// actually join us before opening windows for them.
coroutineScope.launch {
delay(500)
server.autoJoinChannels.forEach { ch ->
val current = openedChannels[server.id] ?: emptyList()
if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch))
onOpenChannel(server.id, ch)
}
}
}
},
onConnect = { connectServer(server) },
onEdit = { editingServer = server; showAddDialog = true },
onDelete = {
repo.remove(server.id)