1 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
6 changed files with 152 additions and 47 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ plugins {
}
group = "com.tpuahsiew.wirc"
version = "0.1.14"
version = "0.1.15"
// No project-level repositories{} block here: settings.gradle.kts sets
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
@@ -40,7 +40,7 @@ compose.desktop {
appResourcesRootDir.set(project.file("installerResources"))
targetFormats(TargetFormat.Msi, TargetFormat.Exe)
packageName = "wIRC"
packageVersion = "0.1.14"
packageVersion = "0.1.15"
windows {
menuGroup = "wIRC"
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
+40 -14
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,6 +19,8 @@ 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
@@ -61,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()
}
@@ -92,20 +119,15 @@ fun main() = application {
val mainLineGap = ui.rememberLineGap()
ui.WindowSnapping(mainWindowState, mainLineGap, osFrame = true)
// 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 },
onToggle = { if (anyHidden) showAll() else hideAll() },
onShow = showAll,
onShowMain = { mainHidden = false },
onHide = hideAll,
onExit = shutdownApp
)
@@ -113,13 +135,13 @@ fun main() = application {
// 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,
visible = windowsShown,
onCloseRequest = { saveSession(); exitApplication() },
visible = !mainHidden,
title = mainTitle,
icon = painterResource("icon.png"),
state = mainWindowState
) {
TrayWindowBehavior(mainWindowState, hideAll)
TrayWindowBehavior(mainWindowState) { mainHidden = true }
androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) {
MaterialTheme(colorScheme = neutralDarkScheme) {
Surface {
@@ -127,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)
},
@@ -145,10 +170,11 @@ fun main() = application {
bufferKey = bufferKey,
prefsRepo = prefsRepo,
colorScheme = neutralDarkScheme,
visible = windowsShown,
onHideAll = hideAll,
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
}
}
}
+11 -6
View File
@@ -27,7 +27,8 @@ 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.
* 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
@@ -35,11 +36,13 @@ 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)
@@ -49,6 +52,7 @@ fun AppTray(
?: 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() } })
@@ -73,17 +77,18 @@ fun AppTray(
/**
* 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.
* - 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, onHideAll: () -> Unit) {
fun FrameWindowScope.TrayWindowBehavior(state: WindowState, onHide: () -> Unit) {
remember(window) { hideFromTaskbar(window) }
val currentHideAll by rememberUpdatedState(onHideAll)
val currentHide by rememberUpdatedState(onHide)
LaunchedEffect(state) {
snapshotFlow { state.isMinimized }.collect { minimized ->
if (minimized) {
currentHideAll()
currentHide()
// Hidden before it's un-minimized, so it doesn't flash back up on the way
window.isVisible = false
state.isMinimized = false
+5 -5
View File
@@ -47,7 +47,7 @@ fun ChannelWindow(
prefsRepo: WindowPrefsRepository,
colorScheme: ColorScheme,
visible: Boolean,
onHideAll: () -> Unit,
onHide: () -> Unit,
onOpenBuffer: (BufferKey) -> Unit,
onCloseSelf: () -> Unit,
onDisconnectServer: () -> Unit
@@ -86,8 +86,8 @@ 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.
// 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,
@@ -98,11 +98,11 @@ fun ChannelWindow(
transparent = true,
resizable = true
) {
TrayWindowBehavior(windowState, onHideAll)
TrayWindowBehavior(windowState, onHide)
CompositionLocalProvider(LocalLineGap provides lineGap) {
MaterialTheme(colorScheme = colorScheme) {
ChannelScreen(
onMinimize = onHideAll,
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)