5 Commits
Author SHA1 Message Date
weishauptandClaude Sonnet 5.5 d58b5772df Bump to 0.1.17
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 14:12:59 -04:00
weishauptandClaude Sonnet 5.5 fff75700ac Fix scrollback snapping back to bottom after scrolling up
Wheel-up now clears stickToBottom immediately on the raw scroll event, wheel-down re-checks the resting position, and the auto-scroll effect re-checks stickToBottom right before scrollToItem(0). The scroll-state effect could miss a wheel scroll that began and ended within one frame, so the next incoming line pulled the buffer back to the bottom.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW57ZvxRmxcDF7AFACLSoV
2026-10-29 18:09:13 +00:00
weishauptandClaude Sonnet 5.5 ed5d9a9c78 Fix line spacing compile error (TextUnit addition), bump to 0.1.16
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:43:19 -04:00
weishauptandClaude Sonnet 5.5 03964ca313 Loosen buffer line spacing for all fonts
Add extra vertical space to every buffer line (LINE_SPACING_EXTRA, 30% of font size) on top of the font natural line height. The same style measures and draws rows so whole-line snapping stays in step.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW57ZwRSygeowNBguQEUUB
2026-10-29 02:30:39 +00:00
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
7 changed files with 210 additions and 60 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins { plugins {
kotlin("jvm") version "1.9.24" kotlin("jvm") version "1.9.24"
@@ -6,7 +6,7 @@ plugins {
} }
group = "com.tpuahsiew.wirc" group = "com.tpuahsiew.wirc"
version = "0.1.14" version = "0.1.17"
// 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
@@ -40,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.14" packageVersion = "0.1.17"
windows { windows {
menuGroup = "wIRC" menuGroup = "wIRC"
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a" upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
+40 -14
View File
@@ -1,6 +1,7 @@
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -18,6 +19,8 @@ import data.AliasRepository
import data.BufferKey import data.BufferKey
import data.MAIN_BUFFER_KEY import data.MAIN_BUFFER_KEY
import data.ServerRepository import data.ServerRepository
import data.SessionState
import data.SessionStateRepository
import data.WindowPrefsRepository import data.WindowPrefsRepository
import data.storageId import data.storageId
import irc.ConnectionManager import irc.ConnectionManager
@@ -61,10 +64,34 @@ fun main() = application {
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) } var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
var mainUnread by remember { mutableStateOf(0) } 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 // 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 // all scrollback to disk), then end the process. A bare /quit in a channel window only
// disconnects that window's server - see ConnectionManager.disconnect. // disconnects that window's server - see ConnectionManager.disconnect.
val shutdownApp: () -> Unit = { val shutdownApp: () -> Unit = {
saveSession()
ConnectionManager.disconnectAll() ConnectionManager.disconnectAll()
exitApplication() exitApplication()
} }
@@ -92,20 +119,15 @@ fun main() = application {
val mainLineGap = ui.rememberLineGap() val mainLineGap = ui.rememberLineGap()
ui.WindowSnapping(mainWindowState, mainLineGap, osFrame = true) 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" val mainTitle = if (mainUnread > 0) "wIRC - Main ($mainUnread new)" else "wIRC - Main"
ui.AppTray( ui.AppTray(
// The unread count shows on the tray icon's tooltip too, so new server messages // The unread count shows on the tray icon's tooltip too, so new server messages
// are visible even when the windows are hidden. // are visible even when the windows are hidden.
tooltip = if (mainUnread > 0) "wIRC ($mainUnread new)" else "wIRC", tooltip = if (mainUnread > 0) "wIRC ($mainUnread new)" else "wIRC",
onToggle = { windowsShown = !windowsShown }, onToggle = { if (anyHidden) showAll() else hideAll() },
onShow = { windowsShown = true }, onShow = showAll,
onShowMain = { mainHidden = false },
onHide = hideAll, onHide = hideAll,
onExit = shutdownApp onExit = shutdownApp
) )
@@ -113,13 +135,13 @@ fun main() = application {
// Closing the main server-list window quits the whole app (and every channel window // 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. // with it) - the tray's Exit does the same, and also disconnects first.
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = { saveSession(); exitApplication() },
visible = windowsShown, visible = !mainHidden,
title = mainTitle, title = mainTitle,
icon = painterResource("icon.png"), icon = painterResource("icon.png"),
state = mainWindowState state = mainWindowState
) { ) {
TrayWindowBehavior(mainWindowState, hideAll) TrayWindowBehavior(mainWindowState) { mainHidden = true }
androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) { androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) {
MaterialTheme(colorScheme = neutralDarkScheme) { MaterialTheme(colorScheme = neutralDarkScheme) {
Surface { Surface {
@@ -127,6 +149,9 @@ fun main() = application {
repo = repo, repo = repo,
colorScheme = neutralDarkScheme, colorScheme = neutralDarkScheme,
displaySettings = mainDisplaySettings, 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 -> onOpenChannel = { serverId, channelName ->
openBuffers = openBuffers + BufferKey(serverId, channelName) openBuffers = openBuffers + BufferKey(serverId, channelName)
}, },
@@ -145,10 +170,11 @@ fun main() = application {
bufferKey = bufferKey, bufferKey = bufferKey,
prefsRepo = prefsRepo, prefsRepo = prefsRepo,
colorScheme = neutralDarkScheme, colorScheme = neutralDarkScheme,
visible = windowsShown, visible = bufferKey !in hiddenBuffers,
onHideAll = hideAll, onHide = { hiddenBuffers = hiddenBuffers + bufferKey },
onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey }, 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 // 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. // gets its "Disconnected" line, even one that hasn't had a line yet.
onDisconnectServer = { 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 * 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 * 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. * Plain AWT instead of Compose's Tray, because Compose's Tray only reports a double click.
*/ */
@Composable @Composable
@@ -35,11 +36,13 @@ fun AppTray(
tooltip: String, tooltip: String,
onToggle: () -> Unit, onToggle: () -> Unit,
onShow: () -> Unit, onShow: () -> Unit,
onShowMain: () -> Unit,
onHide: () -> Unit, onHide: () -> Unit,
onExit: () -> Unit onExit: () -> Unit
) { ) {
val currentToggle by rememberUpdatedState(onToggle) val currentToggle by rememberUpdatedState(onToggle)
val currentShow by rememberUpdatedState(onShow) val currentShow by rememberUpdatedState(onShow)
val currentShowMain by rememberUpdatedState(onShowMain)
val currentHide by rememberUpdatedState(onHide) val currentHide by rememberUpdatedState(onHide)
val currentExit by rememberUpdatedState(onExit) val currentExit by rememberUpdatedState(onExit)
@@ -49,6 +52,7 @@ fun AppTray(
?: return@remember null ?: return@remember null
val menu = PopupMenu().apply { val menu = PopupMenu().apply {
add(MenuItem("Show wIRC").apply { addActionListener { currentShow() } }) add(MenuItem("Show wIRC").apply { addActionListener { currentShow() } })
add(MenuItem("Show wIRC main").apply { addActionListener { currentShowMain() } })
add(MenuItem("Hide wIRC").apply { addActionListener { currentHide() } }) add(MenuItem("Hide wIRC").apply { addActionListener { currentHide() } })
addSeparator() addSeparator()
add(MenuItem("Exit").apply { addActionListener { currentExit() } }) add(MenuItem("Exit").apply { addActionListener { currentExit() } })
@@ -73,17 +77,18 @@ fun AppTray(
/** /**
* Called first thing inside every wIRC Window's content: * Called first thing inside every wIRC Window's content:
* - Keeps the window off the taskbar (see [hideFromTaskbar]). * - Keeps the window off the taskbar (see [hideFromTaskbar]).
* - Turns a minimize (the title-bar button, Win+D, etc.) into hiding every wIRC window to * - Turns a minimize of this window into hiding just this window to the tray ([onHide]):
* the tray: with no taskbar button, a minimized window would have no way back. * with no taskbar button, a minimized window would have no way back. Win+D minimizes
* every window, so it still hides them all.
*/ */
@Composable @Composable
fun FrameWindowScope.TrayWindowBehavior(state: WindowState, onHideAll: () -> Unit) { fun FrameWindowScope.TrayWindowBehavior(state: WindowState, onHide: () -> Unit) {
remember(window) { hideFromTaskbar(window) } remember(window) { hideFromTaskbar(window) }
val currentHideAll by rememberUpdatedState(onHideAll) val currentHide by rememberUpdatedState(onHide)
LaunchedEffect(state) { LaunchedEffect(state) {
snapshotFlow { state.isMinimized }.collect { minimized -> snapshotFlow { state.isMinimized }.collect { minimized ->
if (minimized) { if (minimized) {
currentHideAll() currentHide()
// Hidden before it's un-minimized, so it doesn't flash back up on the way // Hidden before it's un-minimized, so it doesn't flash back up on the way
window.isVisible = false window.isVisible = false
state.isMinimized = false state.isMinimized = false
+57 -12
View File
@@ -1,3 +1,5 @@
@file:OptIn(androidx.compose.ui.text.ExperimentalTextApi::class)
package ui package ui
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
@@ -816,7 +818,7 @@ fun MessageScrollback(
.first { item -> .first { item ->
item == null || (item.index < capturedList.size && item.key == capturedList[item.index].seq) item == null || (item.index < capturedList.size && item.key == capturedList[item.index].seq)
} }
listState.scrollToItem(0) if (stickToBottom) listState.scrollToItem(0)
} }
} }
@@ -847,14 +849,30 @@ fun MessageScrollback(
// then converts that now-stale pixel value using the NEW density, so the // then converts that now-stale pixel value using the NEW density, so the
// two silently disagreed until the window (and this remember scope) was // two silently disagreed until the window (and this remember scope) was
// recreated by closing and reopening it. // recreated by closing and reopening it.
val lineHeightPx = remember(displaySettings.fontSize, displaySettings.fontId, density) { // The buffer text style gets extra vertical air on top of the font's own natural
textMeasurer.measure( // line height (every bundled font has different metrics, so a fixed multiplier
text = "Mg", // would loosen some fonts and tighten others). The same style is used to measure
style = androidx.compose.ui.text.TextStyle( // the line height below AND to draw every row, so the whole-line snapping stays
fontFamily = AppFonts.familyFor(displaySettings.fontId), // exactly in step with what is on screen. LineHeightStyle with Trim.None and a
fontSize = displaySettings.fontSize.sp // centered alignment makes each line exactly lineHeight tall, with the extra
// space split above and below the text.
val bufferTextStyle = remember(displaySettings.fontSize, displaySettings.fontId, density) {
val base = androidx.compose.ui.text.TextStyle(
fontFamily = AppFonts.familyFor(displaySettings.fontId),
fontSize = displaySettings.fontSize.sp
)
val naturalPx = textMeasurer.measure(text = "Mg", style = base).size.height
val spacedLine = with(density) { (naturalPx + (displaySettings.fontSize * LINE_SPACING_EXTRA).sp.toPx()).toSp() }
base.copy(
lineHeight = spacedLine,
lineHeightStyle = androidx.compose.ui.text.style.LineHeightStyle(
alignment = androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center,
trim = androidx.compose.ui.text.style.LineHeightStyle.Trim.None
) )
).size.height )
}
val lineHeightPx = remember(bufferTextStyle, density) {
textMeasurer.measure(text = "Mg", style = bufferTextStyle).size.height
} }
val lineHeightDp = with(density) { lineHeightPx.toDp() } val lineHeightDp = with(density) { lineHeightPx.toDp() }
// Ruled out (see task: newest-message-missing investigation) as the // Ruled out (see task: newest-message-missing investigation) as the
@@ -885,10 +903,7 @@ fun MessageScrollback(
// offset correction through stickToBottom's own effect instead of a // offset correction through stickToBottom's own effect instead of a
// second independent one. // second independent one.
val textStyle = androidx.compose.ui.text.TextStyle( val textStyle = bufferTextStyle
fontFamily = AppFonts.familyFor(displaySettings.fontId),
fontSize = displaySettings.fontSize.sp
)
val selection = selectionState.selection val selection = selectionState.selection
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
@@ -908,6 +923,31 @@ fun MessageScrollback(
.height(snappedHeight) .height(snappedHeight)
.onGloballyPositioned { containerCoords[0] = it } .onGloballyPositioned { containerCoords[0] = it }
.pointerHoverIcon(PointerIcon.Text) .pointerHoverIcon(PointerIcon.Text)
// Wheel-up means the user is reading backscroll: stop sticking to the bottom RIGHT NOW,
// on the raw wheel event. The scroll-state effect up top can miss a wheel scroll that starts
// and finishes inside one frame (isScrollInProgress is never seen as true), which left
// stickToBottom set and let the next incoming line yank the buffer back down.
// Wheel-down re-checks the resting position shortly after and re-enables sticking if the
// user is back at the bottom.
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
if (event.type == PointerEventType.Scroll) {
val dy = event.changes.firstOrNull()?.scrollDelta?.y ?: 0f
if (dy < 0f) {
stickToBottom = false
} else if (dy > 0f) {
coroutineScope.launch {
delay(200)
stickToBottom = listState.firstVisibleItemIndex == 0 &&
listState.firstVisibleItemScrollOffset <= stickToBottomThresholdPx
}
}
}
}
}
}
// Remembers the row under a right-click for the menu's "Copy line". Watches the // Remembers the row under a right-click for the menu's "Copy line". Watches the
// Initial pass, because ContextMenuArea takes the right-press there, before // Initial pass, because ContextMenuArea takes the right-press there, before
// the handler below would see it. // the handler below would see it.
@@ -1114,6 +1154,11 @@ fun MessageScrollback(
} }
} }
// Extra vertical space added to every buffer line, as a fraction of the font size, on top of
// the font's natural line height. 0.30 is a modest loosening; raise it for more air between
// lines (0.45 is roomy), lower it for less. Applies to every font in the font list.
private const val LINE_SPACING_EXTRA = 0.30f
// Reports this element's height as a whole number of text lines ([linePx] each): rounded up // Reports this element's height as a whole number of text lines ([linePx] each): rounded up
// for image slots (the picture is never cut), to the nearest line for text rows (a wrapped // for image slots (the picture is never cut), to the nearest line for text rows (a wrapped
// message can measure a pixel or two off an exact multiple). Content is drawn from the top. // message can measure a pixel or two off an exact multiple). Content is drawn from the top.
+5 -5
View File
@@ -47,7 +47,7 @@ fun ChannelWindow(
prefsRepo: WindowPrefsRepository, prefsRepo: WindowPrefsRepository,
colorScheme: ColorScheme, colorScheme: ColorScheme,
visible: Boolean, visible: Boolean,
onHideAll: () -> Unit, onHide: () -> Unit,
onOpenBuffer: (BufferKey) -> Unit, onOpenBuffer: (BufferKey) -> Unit,
onCloseSelf: () -> Unit, onCloseSelf: () -> Unit,
onDisconnectServer: () -> 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 // 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 // Hidden to the tray when [visible] is false - the window and its composition stay
// window and its composition stay alive, so its buffer keeps filling while hidden. // alive, so its buffer keeps filling while hidden.
Window( Window(
onCloseRequest = onCloseSelf, onCloseRequest = onCloseSelf,
visible = visible, visible = visible,
@@ -98,11 +98,11 @@ fun ChannelWindow(
transparent = true, transparent = true,
resizable = true resizable = true
) { ) {
TrayWindowBehavior(windowState, onHideAll) TrayWindowBehavior(windowState, onHide)
CompositionLocalProvider(LocalLineGap provides lineGap) { CompositionLocalProvider(LocalLineGap provides lineGap) {
MaterialTheme(colorScheme = colorScheme) { MaterialTheme(colorScheme = colorScheme) {
ChannelScreen( ChannelScreen(
onMinimize = onHideAll, onMinimize = onHide,
onCloseWindow = onCloseSelf, onCloseWindow = onCloseSelf,
channelName = windowTitle, channelName = windowTitle,
buffer = buffer, buffer = buffer,
+28 -20
View File
@@ -41,6 +41,7 @@ fun MainScreen(
repo: ServerRepository, repo: ServerRepository,
colorScheme: ColorScheme, colorScheme: ColorScheme,
displaySettings: WindowDisplaySettings, displaySettings: WindowDisplaySettings,
autoConnectServerIds: Set<String>,
onOpenChannel: (serverId: String, channelName: String) -> Unit, onOpenChannel: (serverId: String, channelName: String) -> Unit,
onExitApp: () -> Unit, onExitApp: () -> Unit,
onUnreadChange: (Int) -> Unit, onUnreadChange: (Int) -> Unit,
@@ -57,13 +58,38 @@ fun MainScreen(
var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) } var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
val coroutineScope = rememberCoroutineScope() 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 buffers by ConnectionManager.buffers.collectAsState()
val mainMessages by remember { derivedStateOf { buffers[MAIN_BUFFER_KEY]?.messages ?: emptyList() } } val mainMessages by remember { derivedStateOf { buffers[MAIN_BUFFER_KEY]?.messages ?: emptyList() } }
val textColor = mircPalette[displaySettings.textColorMircIndex] val textColor = mircPalette[displaySettings.textColorMircIndex]
// Unread indicator: lines that arrived in wIRC main while this window didn't have focus // 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, // (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 // focused. Compared by timestamp rather than count so history loaded at startup never
// counts as new. // counts as new.
val windowFocused = LocalWindowInfo.current.isWindowFocused val windowFocused = LocalWindowInfo.current.isWindowFocused
@@ -116,25 +142,7 @@ fun MainScreen(
channelInput = channelInputs[server.id] ?: "#", channelInput = channelInputs[server.id] ?: "#",
openedChannels = openedChannels[server.id] ?: emptyList(), openedChannels = openedChannels[server.id] ?: emptyList(),
onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) }, onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) },
onConnect = { onConnect = { connectServer(server) },
// 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)
}
}
}
},
onEdit = { editingServer = server; showAddDialog = true }, onEdit = { editingServer = server; showAddDialog = true },
onDelete = { onDelete = {
repo.remove(server.id) repo.remove(server.id)