Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d58b5772df | ||
|
|
fff75700ac | ||
|
|
ed5d9a9c78 | ||
|
|
03964ca313 | ||
|
|
78bd235a48 | ||
|
|
24f54574ec | ||
|
|
4ac7b75962 |
+5
-3
@@ -1,4 +1,4 @@
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
|
||||
plugins {
|
||||
kotlin("jvm") version "1.9.24"
|
||||
@@ -6,7 +6,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "com.tpuahsiew.wirc"
|
||||
version = "0.1.12"
|
||||
version = "0.1.17"
|
||||
|
||||
// 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.12"
|
||||
packageVersion = "0.1.17"
|
||||
windows {
|
||||
menuGroup = "wIRC"
|
||||
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
|
||||
|
||||
+54
-9
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:OptIn(androidx.compose.ui.text.ExperimentalTextApi::class)
|
||||
|
||||
package ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
@@ -61,6 +63,7 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
@@ -78,7 +81,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
|
||||
@@ -183,7 +185,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?,
|
||||
@@ -382,7 +384,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)
|
||||
@@ -816,7 +818,7 @@ fun MessageScrollback(
|
||||
.first { item ->
|
||||
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
|
||||
// two silently disagreed until the window (and this remember scope) was
|
||||
// recreated by closing and reopening it.
|
||||
val lineHeightPx = remember(displaySettings.fontSize, displaySettings.fontId, density) {
|
||||
textMeasurer.measure(
|
||||
text = "Mg",
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
fontFamily = AppFonts.familyFor(displaySettings.fontId),
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
// The buffer text style gets extra vertical air on top of the font's own natural
|
||||
// line height (every bundled font has different metrics, so a fixed multiplier
|
||||
// would loosen some fonts and tighten others). The same style is used to measure
|
||||
// the line height below AND to draw every row, so the whole-line snapping stays
|
||||
// exactly in step with what is on screen. LineHeightStyle with Trim.None and a
|
||||
// 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() }
|
||||
// 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
|
||||
// second independent one.
|
||||
|
||||
val textStyle = androidx.compose.ui.text.TextStyle(
|
||||
fontFamily = AppFonts.familyFor(displaySettings.fontId),
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
val textStyle = bufferTextStyle
|
||||
val selection = selectionState.selection
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -908,6 +923,31 @@ fun MessageScrollback(
|
||||
.height(snappedHeight)
|
||||
.onGloballyPositioned { containerCoords[0] = it }
|
||||
.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
|
||||
// Initial pass, because ContextMenuArea takes the right-press there, before
|
||||
// the handler below would see it.
|
||||
@@ -1059,7 +1099,10 @@ fun MessageScrollback(
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// Every row is kept a whole number of lines tall (nearest whole line, to absorb
|
||||
// the pixel or two a wrapped message can be off by), so the line snapping above
|
||||
// stays lined up all the way to the top of the window instead of drifting.
|
||||
Column(modifier = Modifier.fillMaxWidth().snapHeightToLines(lineHeightPx, roundUp = false)) {
|
||||
// Timestamp + nick are one text and the body another, both BasicText with
|
||||
// the same explicit style - BasicText never merges the ambient LocalTextStyle,
|
||||
// so neither picks up MaterialTheme's bodyLarge 24sp lineHeight (which once
|
||||
@@ -1085,14 +1128,19 @@ fun MessageScrollback(
|
||||
}
|
||||
if (displaySettings.showImagePreviews) {
|
||||
extractImageUrls(msg.rawText).forEach { url ->
|
||||
UrlImagePreview(
|
||||
url = url,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, top = 2.dp, bottom = 4.dp)
|
||||
.heightIn(max = 200.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
onClick = { openUrlInBrowser(url) }
|
||||
)
|
||||
// A thumbnail can be any height, so its slot is rounded UP to whole lines
|
||||
// (never cropping the picture); otherwise every line above it in the
|
||||
// window would sit a fraction of a line off and show cut in half at the top.
|
||||
Box(modifier = Modifier.snapHeightToLines(lineHeightPx, roundUp = true)) {
|
||||
UrlImagePreview(
|
||||
url = url,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, top = 2.dp, bottom = 4.dp)
|
||||
.heightIn(max = 200.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
onClick = { openUrlInBrowser(url) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1106,6 +1154,25 @@ 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
|
||||
// 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.
|
||||
private fun Modifier.snapHeightToLines(linePx: Int, roundUp: Boolean): Modifier = layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints.copy(minHeight = 0))
|
||||
val h = placeable.height
|
||||
val snapped = when {
|
||||
linePx <= 0 || h == 0 -> h
|
||||
roundUp -> ((h + linePx - 1) / linePx) * linePx
|
||||
else -> (((h + linePx / 2) / linePx).coerceAtLeast(1)) * linePx
|
||||
}.coerceIn(constraints.minHeight, constraints.maxHeight.coerceAtLeast(constraints.minHeight))
|
||||
layout(placeable.width, snapped) { placeable.place(0, 0) }
|
||||
}
|
||||
|
||||
// Which of a row's two texts a TextPos is in: the timestamp/nick prefix, or the body.
|
||||
private const val PART_PREFIX = 0
|
||||
private const val PART_BODY = 1
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user