Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d58b5772df | ||
|
|
fff75700ac | ||
|
|
ed5d9a9c78 | ||
|
|
03964ca313 | ||
|
|
78bd235a48 | ||
|
|
24f54574ec | ||
|
|
4ac7b75962 | ||
|
|
5bcd1a7bcd | ||
|
|
026e349db8 | ||
|
|
efad80e141 | ||
|
|
3538274805 | ||
|
|
d9a0297cc7 | ||
|
|
807903fe0f | ||
|
|
242d7a7c42 |
+5
-3
@@ -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.5"
|
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
|
||||||
@@ -22,6 +22,8 @@ dependencies {
|
|||||||
implementation(compose.materialIconsExtended)
|
implementation(compose.materialIconsExtended)
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1")
|
||||||
implementation("org.json:json:20240303")
|
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 {
|
kotlin {
|
||||||
@@ -38,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.5"
|
packageVersion = "0.1.17"
|
||||||
windows {
|
windows {
|
||||||
menuGroup = "wIRC"
|
menuGroup = "wIRC"
|
||||||
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
|
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
|
||||||
|
|||||||
+90
-16
@@ -1,7 +1,9 @@
|
|||||||
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.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -9,16 +11,23 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
import androidx.compose.ui.window.rememberWindowState
|
|
||||||
import data.AliasRepository
|
import data.AliasRepository
|
||||||
import data.BufferKey
|
import data.BufferKey
|
||||||
|
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 irc.ConnectionManager
|
import irc.ConnectionManager
|
||||||
import ui.ChannelWindow
|
import ui.ChannelWindow
|
||||||
import ui.MainScreen
|
import ui.MainScreen
|
||||||
|
import ui.TrayWindowBehavior
|
||||||
|
import ui.rememberPersistentWindowState
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
// Material 3's darkColorScheme() defaults to a purple accent; this keeps everything
|
// Material 3's darkColorScheme() defaults to a purple accent; this keeps everything
|
||||||
@@ -46,16 +55,43 @@ fun main() = application {
|
|||||||
// Loaded once here (not per-window) so the alias map is truly global - every
|
// Loaded once here (not per-window) so the alias map is truly global - every
|
||||||
// ChannelWindow's command router reads from this same AliasRepository singleton.
|
// ChannelWindow's command router reads from this same AliasRepository singleton.
|
||||||
AliasRepository.init()
|
AliasRepository.init()
|
||||||
|
// Ask Windows for the desktop icon grid now, so the first window drag can already snap to it
|
||||||
|
ui.DesktopGrid.refreshAsync()
|
||||||
|
|
||||||
val repo = remember { ServerRepository(appRoot) }
|
val repo = remember { ServerRepository(appRoot) }
|
||||||
val prefsRepo = remember { WindowPrefsRepository(appRoot) }
|
val prefsRepo = remember { WindowPrefsRepository(appRoot) }
|
||||||
|
|
||||||
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
|
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
|
||||||
|
var mainUnread by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
// Full shutdown: same steps /quit (bare, no args) already runs from a channel window -
|
// wIRC lives in the tray: no window is on the taskbar, and minimizing a window hides just
|
||||||
// disconnect every server, then end the process. Shared here so the "Exit wIRC" button
|
// that window (see TrayWindowBehavior). Hidden windows stay composed and connected, so
|
||||||
// and every channel window's /quit reuse the exact same logic instead of duplicating it.
|
// 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 = {
|
val shutdownApp: () -> Unit = {
|
||||||
|
saveSession()
|
||||||
ConnectionManager.disconnectAll()
|
ConnectionManager.disconnectAll()
|
||||||
exitApplication()
|
exitApplication()
|
||||||
}
|
}
|
||||||
@@ -65,34 +101,67 @@ fun main() = application {
|
|||||||
// ConnectionManager's sockets just live for as long as this process runs, and this
|
// ConnectionManager's sockets just live for as long as this process runs, and this
|
||||||
// listener (which the Android foreground service used to own) can live directly at
|
// listener (which the Android foreground service used to own) can live directly at
|
||||||
// the application level instead.
|
// the application level instead.
|
||||||
|
//
|
||||||
|
// Fires for every private message to or from a real user, so a query window the user
|
||||||
|
// closed reopens when that person messages again. Adding a key that's already open is
|
||||||
|
// a no-op (openBuffers is a set).
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
ConnectionManager.newQueryBuffers.collect { key ->
|
ConnectionManager.queryWindowRequests.collect { key ->
|
||||||
openBuffers = openBuffers + key
|
openBuffers = openBuffers + key
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// There's no persistent tray/notification surface on this build, so closing the main
|
// Restored from / saved to window_prefs.json under MAIN_BUFFER_KEY's storage id
|
||||||
// server-list window quits the whole app (and every channel window with it) - the
|
// ("wirc-main__main"), same as every channel and PM window - see WindowPlacement.kt.
|
||||||
// same "closing the primary window ends the session" convention most desktop chat
|
val mainWindowState = rememberPersistentWindowState(prefsRepo, MAIN_BUFFER_KEY.storageId(), DpSize(800.dp, 700.dp))
|
||||||
// apps without a tray icon follow.
|
val mainDisplaySettings by prefsRepo.settingsFor(MAIN_BUFFER_KEY.storageId()).collectAsState()
|
||||||
|
// Snap to the desktop grid after a move, and to whole lines after a resize
|
||||||
|
val mainLineGap = ui.rememberLineGap()
|
||||||
|
ui.WindowSnapping(mainWindowState, mainLineGap, osFrame = true)
|
||||||
|
|
||||||
|
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(
|
Window(
|
||||||
onCloseRequest = ::exitApplication,
|
onCloseRequest = { saveSession(); exitApplication() },
|
||||||
title = "wIRC - Main",
|
visible = !mainHidden,
|
||||||
|
title = mainTitle,
|
||||||
icon = painterResource("icon.png"),
|
icon = painterResource("icon.png"),
|
||||||
state = rememberWindowState()
|
state = mainWindowState
|
||||||
) {
|
) {
|
||||||
|
TrayWindowBehavior(mainWindowState) { mainHidden = true }
|
||||||
|
androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) {
|
||||||
MaterialTheme(colorScheme = neutralDarkScheme) {
|
MaterialTheme(colorScheme = neutralDarkScheme) {
|
||||||
Surface {
|
Surface {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
repo = repo,
|
repo = repo,
|
||||||
colorScheme = neutralDarkScheme,
|
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 ->
|
onOpenChannel = { serverId, channelName ->
|
||||||
openBuffers = openBuffers + BufferKey(serverId, channelName)
|
openBuffers = openBuffers + BufferKey(serverId, channelName)
|
||||||
},
|
},
|
||||||
onExitApp = shutdownApp
|
onExitApp = shutdownApp,
|
||||||
|
onUnreadChange = { mainUnread = it },
|
||||||
|
onFontChange = { id -> prefsRepo.setFontId(MAIN_BUFFER_KEY.storageId(), id) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
openBuffers.forEach { bufferKey ->
|
openBuffers.forEach { bufferKey ->
|
||||||
@@ -101,10 +170,15 @@ fun main() = application {
|
|||||||
bufferKey = bufferKey,
|
bufferKey = bufferKey,
|
||||||
prefsRepo = prefsRepo,
|
prefsRepo = prefsRepo,
|
||||||
colorScheme = neutralDarkScheme,
|
colorScheme = neutralDarkScheme,
|
||||||
|
visible = bufferKey !in hiddenBuffers,
|
||||||
|
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
|
||||||
onQuitApp = {
|
onCloseSelf = { openBuffers = openBuffers - bufferKey; hiddenBuffers = hiddenBuffers - bufferKey },
|
||||||
exitApplication()
|
// 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 = {
|
||||||
|
ConnectionManager.disconnect(bufferKey.serverId, openBuffers)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,6 @@ import java.io.File
|
|||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
|
||||||
// TEMP DEBUG (see task: alias multi-word arguments investigation) - remove once confirmed
|
|
||||||
// fixed. Traces the full alias pipeline: raw input -> matched alias -> substituted target
|
|
||||||
// -> the actual Desktop API call, so a silent failure anywhere in that chain is visible.
|
|
||||||
private fun aliasDebug(msg: String) {
|
|
||||||
println("[alias-debug] $msg")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name reserved for the app-level /reload command (ChannelWindow.kt handles it directly,
|
// Name reserved for the app-level /reload command (ChannelWindow.kt handles it directly,
|
||||||
// before alias lookup even runs) - kept here too so a line in aliases.txt can never define
|
// before alias lookup even runs) - kept here too so a line in aliases.txt can never define
|
||||||
// an alias that would shadow it.
|
// an alias that would shadow it.
|
||||||
@@ -57,6 +50,12 @@ object AliasRepository {
|
|||||||
return aliases.size
|
return aliases.size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True if [command] (without the leading '/') is an alias - those run locally, no server needed. */
|
||||||
|
fun hasAlias(command: String): Boolean {
|
||||||
|
val key = command.lowercase()
|
||||||
|
return key != RESERVED_RELOAD && key in aliases
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if [command] (without the leading '/') matched a known alias and its
|
* Returns true if [command] (without the leading '/') matched a known alias and its
|
||||||
* action was run - callers should treat that as "handled, don't fall through to IRC
|
* action was run - callers should treat that as "handled, don't fall through to IRC
|
||||||
@@ -64,15 +63,9 @@ object AliasRepository {
|
|||||||
* "reload" name, which this repository never loads as an alias).
|
* "reload" name, which this repository never loads as an alias).
|
||||||
*/
|
*/
|
||||||
fun tryRunAlias(command: String, args: String): Boolean {
|
fun tryRunAlias(command: String, args: String): Boolean {
|
||||||
aliasDebug("tryRunAlias: command=\"$command\" args=\"$args\"")
|
|
||||||
val key = command.lowercase()
|
val key = command.lowercase()
|
||||||
if (key == RESERVED_RELOAD) return false
|
if (key == RESERVED_RELOAD) return false
|
||||||
val action = aliases[key]
|
val action = aliases[key] ?: return false
|
||||||
if (action == null) {
|
|
||||||
aliasDebug("no alias matched for \"$key\"")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
aliasDebug("matched \"$key\" -> \"$action\"")
|
|
||||||
runAction(action, args)
|
runAction(action, args)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -94,13 +87,12 @@ object AliasRepository {
|
|||||||
// literal, not a URL delimiter.
|
// literal, not a URL delimiter.
|
||||||
val substitutedArg = if (type == "url") encodeArgForUrl(args) else args
|
val substitutedArg = if (type == "url") encodeArgForUrl(args) else args
|
||||||
val target = if (rawTarget.contains("{arg}")) rawTarget.replace("{arg}", substitutedArg) else rawTarget
|
val target = if (rawTarget.contains("{arg}")) rawTarget.replace("{arg}", substitutedArg) else rawTarget
|
||||||
aliasDebug("type=\"$type\" rawTarget=\"$rawTarget\" substitutedArg=\"$substitutedArg\" -> target=\"$target\"")
|
|
||||||
if (target.isBlank()) return
|
if (target.isBlank()) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
when (type) {
|
when (type) {
|
||||||
"path" -> { aliasDebug("Desktop.open(File(\"$target\"))"); Desktop.getDesktop().open(File(target)) }
|
"path" -> Desktop.getDesktop().open(File(target))
|
||||||
"url" -> { aliasDebug("Desktop.browse(URI(\"$target\"))"); Desktop.getDesktop().browse(URI(target)) }
|
"url" -> Desktop.getDesktop().browse(URI(target))
|
||||||
// Routed through "cmd /c start" (same launch path as double-clicking in
|
// Routed through "cmd /c start" (same launch path as double-clicking in
|
||||||
// Explorer or the Run dialog) rather than exec'd directly. A direct
|
// Explorer or the Run dialog) rather than exec'd directly. A direct
|
||||||
// ProcessBuilder(target).start() calls CreateProcess, which does NOT honor
|
// ProcessBuilder(target).start() calls CreateProcess, which does NOT honor
|
||||||
@@ -110,7 +102,7 @@ object AliasRepository {
|
|||||||
// would. This also preserves bare-name resolution via Windows' App Paths
|
// would. This also preserves bare-name resolution via Windows' App Paths
|
||||||
// registry / PATH - not guaranteed to work for every app, since it depends
|
// registry / PATH - not guaranteed to work for every app, since it depends
|
||||||
// on that app having registered itself with Windows.
|
// on that app having registered itself with Windows.
|
||||||
"run" -> { aliasDebug("ProcessBuilder start \"$target\""); ProcessBuilder("cmd", "/c", "start", "\"\"", target).start() }
|
"run" -> ProcessBuilder("cmd", "/c", "start", "\"\"", target).start()
|
||||||
else -> println("[AliasRepository] Unknown alias action type '$type' in \"$action\"")
|
else -> println("[AliasRepository] Unknown alias action type '$type' in \"$action\"")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -47,6 +47,35 @@ data class IrcMessage(
|
|||||||
/** Buffer key: server id + channel/query name (channel names include '#'). */
|
/** Buffer key: server id + channel/query name (channel names include '#'). */
|
||||||
data class BufferKey(val serverId: String, val name: String)
|
data class BufferKey(val serverId: String, val name: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wIRC main's own buffer: server messages (MOTD, numerics, errors, all whois output) and
|
||||||
|
* services/server notices from every connected server. Server ids are built from the host
|
||||||
|
* with every non-alphanumeric character replaced by '_' (see ServerEditDialog), so one can
|
||||||
|
* never contain '-' - this id can't collide with a real server. Also used as the
|
||||||
|
* window_prefs.json key ("wirc-main__main") for the main window's saved position and size.
|
||||||
|
*/
|
||||||
|
val MAIN_BUFFER_KEY = BufferKey("wirc-main", "main")
|
||||||
|
|
||||||
|
// Nicks that are network services rather than people. Their PRIVMSGs and NOTICEs go to
|
||||||
|
// wIRC main instead of opening a query window. Anything ending in "serv" is also treated
|
||||||
|
// as a service, which covers the less common ones (MemoServ, HostServ, SaslServ, ...).
|
||||||
|
private val KNOWN_SERVICE_NICKS = setOf("nickserv", "chanserv", "global", "alis", "q", "x")
|
||||||
|
|
||||||
|
fun isServiceNick(nick: String): Boolean {
|
||||||
|
val lower = nick.lowercase()
|
||||||
|
return lower in KNOWN_SERVICE_NICKS || lower.endsWith("serv")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a message's source is a service or the server itself rather than a real user.
|
||||||
|
* A user prefix always has the "nick!user@host" form; a missing prefix or one without '!'
|
||||||
|
* is a server name (e.g. "irc.rizon.io").
|
||||||
|
*/
|
||||||
|
fun isServiceOrServerPrefix(prefix: String?): Boolean {
|
||||||
|
if (prefix == null || !prefix.contains('!')) return true
|
||||||
|
return isServiceNick(prefix.substringBefore('!'))
|
||||||
|
}
|
||||||
|
|
||||||
data class ChannelUser(
|
data class ChannelUser(
|
||||||
val nick: String,
|
val nick: String,
|
||||||
val isOp: Boolean = false,
|
val isOp: Boolean = false,
|
||||||
|
|||||||
@@ -42,16 +42,10 @@ class ServerRepository(rootDir: File) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun upsert(server: ServerConfig) {
|
fun upsert(server: ServerConfig) {
|
||||||
// TEMP DEBUG (see task: on-connect identify command investigation) - confirms the
|
|
||||||
// Edit Server dialog's Save actually persists onConnectCommands, by writing then
|
|
||||||
// immediately re-reading the file back rather than trusting the in-memory value.
|
|
||||||
println("[ServerRepository] upsert(${server.id}): saving onConnectCommands=${server.onConnectCommands}")
|
|
||||||
val current = loadAll().toMutableList()
|
val current = loadAll().toMutableList()
|
||||||
val idx = current.indexOfFirst { it.id == server.id }
|
val idx = current.indexOfFirst { it.id == server.id }
|
||||||
if (idx >= 0) current[idx] = server else current.add(server)
|
if (idx >= 0) current[idx] = server else current.add(server)
|
||||||
saveAll(current)
|
saveAll(current)
|
||||||
val reloaded = loadAll().firstOrNull { it.id == server.id }
|
|
||||||
println("[ServerRepository] upsert(${server.id}): reloaded from disk onConnectCommands=${reloaded?.onConnectCommands}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(serverId: String) {
|
fun remove(serverId: String) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,31 +5,64 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.nio.file.AtomicMoveNotSupportedException
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
// Font sizes offered in the settings dialog (whole sizes only, same range as QuickClaude)
|
||||||
|
const val MIN_FONT_SIZE = 10
|
||||||
|
const val MAX_FONT_SIZE = 18
|
||||||
|
|
||||||
|
private fun pickerFontSize(size: Float): Float =
|
||||||
|
Math.round(size).coerceIn(MIN_FONT_SIZE, MAX_FONT_SIZE).toFloat()
|
||||||
|
|
||||||
data class WindowDisplaySettings(
|
data class WindowDisplaySettings(
|
||||||
val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque
|
val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque
|
||||||
val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white
|
val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white
|
||||||
val fontSize: Float = 14f,
|
val fontSize: Float = 14f,
|
||||||
|
val fontId: String = "monospace", // see ui/AppFonts.kt; unknown/missing ids fall back to monospace
|
||||||
val showUserList: Boolean = true,
|
val showUserList: Boolean = true,
|
||||||
val mentionHighlightColorArgb: Int = 0x40FFA500.toInt(), // translucent orange
|
val mentionHighlightColorArgb: Int = 0x40FFA500.toInt(), // translucent orange
|
||||||
val nickColorsEnabled: Boolean = true,
|
val nickColorsEnabled: Boolean = true,
|
||||||
val showImagePreviews: Boolean = false // opt-in: auto-loading images reveals your IP to the host
|
val showImagePreviews: Boolean = false // opt-in: auto-loading images reveals your IP to the host
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A window's last floating position and size, in dp (which Compose Desktop maps 1:1 to
|
||||||
|
* AWT's logical screen coordinates). Every field is optional in window_prefs.json, so files
|
||||||
|
* written before this existed still load - a window with no saved bounds just gets a
|
||||||
|
* cascaded default (see ui/WindowPlacement.kt).
|
||||||
|
*/
|
||||||
|
data class WindowBounds(
|
||||||
|
val x: Float?,
|
||||||
|
val y: Float?,
|
||||||
|
val width: Float?,
|
||||||
|
val height: Float?
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop equivalent of the Android DataStore-backed repository. Settings for every
|
* Desktop equivalent of the Android DataStore-backed repository. Settings for every
|
||||||
* buffer are kept as one JSON file (rootDir/window_prefs.json), keyed by buffer storage
|
* buffer are kept as one JSON file (rootDir/window_prefs.json), keyed by buffer storage
|
||||||
* id, loaded once at startup and rewritten on every change. Each buffer's settings are
|
* id, loaded once at startup and rewritten on every change. Each buffer's settings are
|
||||||
* exposed as a StateFlow so every open window for that buffer updates live, same as
|
* exposed as a StateFlow so every open window for that buffer updates live, same as
|
||||||
* DataStore's Flow did on Android.
|
* DataStore's Flow did on Android.
|
||||||
|
*
|
||||||
|
* Window bounds live in the same JSON object per key (windowX/windowY/windowWidth/
|
||||||
|
* windowHeight) but are kept out of the display-settings StateFlow, so moving or resizing
|
||||||
|
* a window doesn't recompose its content.
|
||||||
*/
|
*/
|
||||||
class WindowPrefsRepository(rootDir: File) {
|
class WindowPrefsRepository(rootDir: File) {
|
||||||
|
|
||||||
private val file = File(rootDir, "window_prefs.json")
|
private val file = File(rootDir, "window_prefs.json")
|
||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
private val flows = ConcurrentHashMap<String, MutableStateFlow<WindowDisplaySettings>>()
|
private val flows = ConcurrentHashMap<String, MutableStateFlow<WindowDisplaySettings>>()
|
||||||
private var stored: MutableMap<String, WindowDisplaySettings> = loadAll()
|
private val stored = mutableMapOf<String, WindowDisplaySettings>()
|
||||||
|
private val storedBounds = mutableMapOf<String, WindowBounds>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
fun settingsFor(bufferKeyId: String): StateFlow<WindowDisplaySettings> = flowFor(bufferKeyId).asStateFlow()
|
fun settingsFor(bufferKeyId: String): StateFlow<WindowDisplaySettings> = flowFor(bufferKeyId).asStateFlow()
|
||||||
|
|
||||||
@@ -37,7 +70,9 @@ class WindowPrefsRepository(rootDir: File) {
|
|||||||
|
|
||||||
fun setTextColorMircIndex(bufferKeyId: String, index: Int) = update(bufferKeyId) { it.copy(textColorMircIndex = index) }
|
fun setTextColorMircIndex(bufferKeyId: String, index: Int) = update(bufferKeyId) { it.copy(textColorMircIndex = index) }
|
||||||
|
|
||||||
fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = fontSize) }
|
fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = pickerFontSize(fontSize)) }
|
||||||
|
|
||||||
|
fun setFontId(bufferKeyId: String, fontId: String) = update(bufferKeyId) { it.copy(fontId = fontId) }
|
||||||
|
|
||||||
fun setShowUserList(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showUserList = show) }
|
fun setShowUserList(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showUserList = show) }
|
||||||
|
|
||||||
@@ -47,8 +82,18 @@ class WindowPrefsRepository(rootDir: File) {
|
|||||||
|
|
||||||
fun setShowImagePreviews(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showImagePreviews = show) }
|
fun setShowImagePreviews(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showImagePreviews = show) }
|
||||||
|
|
||||||
|
fun boundsFor(bufferKeyId: String): WindowBounds? = synchronized(lock) { storedBounds[bufferKeyId] }
|
||||||
|
|
||||||
|
fun setWindowBounds(bufferKeyId: String, bounds: WindowBounds) {
|
||||||
|
synchronized(lock) {
|
||||||
|
if (storedBounds[bufferKeyId] == bounds) return
|
||||||
|
storedBounds[bufferKeyId] = bounds
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun flowFor(bufferKeyId: String): MutableStateFlow<WindowDisplaySettings> =
|
private fun flowFor(bufferKeyId: String): MutableStateFlow<WindowDisplaySettings> =
|
||||||
flows.getOrPut(bufferKeyId) { MutableStateFlow(stored[bufferKeyId] ?: WindowDisplaySettings()) }
|
flows.getOrPut(bufferKeyId) { MutableStateFlow(synchronized(lock) { stored[bufferKeyId] } ?: WindowDisplaySettings()) }
|
||||||
|
|
||||||
private fun update(bufferKeyId: String, transform: (WindowDisplaySettings) -> WindowDisplaySettings) {
|
private fun update(bufferKeyId: String, transform: (WindowDisplaySettings) -> WindowDisplaySettings) {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
@@ -59,44 +104,74 @@ class WindowPrefsRepository(rootDir: File) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadAll(): MutableMap<String, WindowDisplaySettings> {
|
private fun JSONObject.optFloatOrNull(key: String): Float? =
|
||||||
if (!file.exists()) return mutableMapOf()
|
if (has(key) && !isNull(key)) optDouble(key).toFloat().takeIf { !it.isNaN() } else null
|
||||||
return try {
|
|
||||||
|
private fun loadAll() {
|
||||||
|
if (!file.exists()) return
|
||||||
|
try {
|
||||||
val obj = JSONObject(file.readText())
|
val obj = JSONObject(file.readText())
|
||||||
val result = mutableMapOf<String, WindowDisplaySettings>()
|
|
||||||
obj.keys().forEach { key ->
|
obj.keys().forEach { key ->
|
||||||
val o = obj.getJSONObject(key)
|
val o = obj.getJSONObject(key)
|
||||||
result[key] = WindowDisplaySettings(
|
stored[key] = WindowDisplaySettings(
|
||||||
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
|
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
|
||||||
textColorMircIndex = o.optInt("textColorMircIndex", 0),
|
textColorMircIndex = o.optInt("textColorMircIndex", 0),
|
||||||
fontSize = o.optDouble("fontSize", 14.0).toFloat(),
|
// Sizes saved by the old slider (any value from 10 to 24) round to the nearest picker size
|
||||||
|
fontSize = pickerFontSize(o.optDouble("fontSize", 14.0).toFloat()),
|
||||||
|
fontId = o.optString("fontId", "monospace"),
|
||||||
showUserList = o.optBoolean("showUserList", true),
|
showUserList = o.optBoolean("showUserList", true),
|
||||||
mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(),
|
mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(),
|
||||||
nickColorsEnabled = o.optBoolean("nickColorsEnabled", true),
|
nickColorsEnabled = o.optBoolean("nickColorsEnabled", true),
|
||||||
showImagePreviews = o.optBoolean("showImagePreviews", false)
|
showImagePreviews = o.optBoolean("showImagePreviews", false)
|
||||||
)
|
)
|
||||||
|
val bounds = WindowBounds(
|
||||||
|
x = o.optFloatOrNull("windowX"),
|
||||||
|
y = o.optFloatOrNull("windowY"),
|
||||||
|
width = o.optFloatOrNull("windowWidth"),
|
||||||
|
height = o.optFloatOrNull("windowHeight")
|
||||||
|
)
|
||||||
|
if (bounds != WindowBounds(null, null, null, null)) storedBounds[key] = bounds
|
||||||
}
|
}
|
||||||
result
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
mutableMapOf()
|
stored.clear()
|
||||||
|
storedBounds.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Written to a temp file and moved into place, same as MessageHistoryRepository - this
|
||||||
|
// file is now rewritten every time a window is moved or resized, so a crash mid-write
|
||||||
|
// must not be able to leave a truncated file (which loadAll would read as "no settings
|
||||||
|
// at all" and the next save would make permanent).
|
||||||
private fun persist() {
|
private fun persist() {
|
||||||
val obj = JSONObject()
|
val obj = JSONObject()
|
||||||
stored.forEach { (key, settings) ->
|
(stored.keys + storedBounds.keys).forEach { key ->
|
||||||
obj.put(key, JSONObject().apply {
|
obj.put(key, JSONObject().apply {
|
||||||
put("backgroundAlpha", settings.backgroundAlpha)
|
stored[key]?.let { settings ->
|
||||||
put("textColorMircIndex", settings.textColorMircIndex)
|
put("backgroundAlpha", settings.backgroundAlpha)
|
||||||
put("fontSize", settings.fontSize)
|
put("textColorMircIndex", settings.textColorMircIndex)
|
||||||
put("showUserList", settings.showUserList)
|
put("fontSize", settings.fontSize)
|
||||||
put("mentionHighlightColorArgb", settings.mentionHighlightColorArgb)
|
put("fontId", settings.fontId)
|
||||||
put("nickColorsEnabled", settings.nickColorsEnabled)
|
put("showUserList", settings.showUserList)
|
||||||
put("showImagePreviews", settings.showImagePreviews)
|
put("mentionHighlightColorArgb", settings.mentionHighlightColorArgb)
|
||||||
|
put("nickColorsEnabled", settings.nickColorsEnabled)
|
||||||
|
put("showImagePreviews", settings.showImagePreviews)
|
||||||
|
}
|
||||||
|
storedBounds[key]?.let { bounds ->
|
||||||
|
bounds.x?.let { put("windowX", it) }
|
||||||
|
bounds.y?.let { put("windowY", it) }
|
||||||
|
bounds.width?.let { put("windowWidth", it) }
|
||||||
|
bounds.height?.let { put("windowHeight", it) }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
file.parentFile?.mkdirs()
|
file.parentFile?.mkdirs()
|
||||||
file.writeText(obj.toString())
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
// Per-buffer message history cap. Named so it's a one-line change to temporarily lower
|
// Per-buffer message history cap. Named so it's a one-line change to temporarily lower
|
||||||
@@ -28,6 +31,29 @@ private const val MESSAGE_HISTORY_CAP = 500
|
|||||||
// reported slowness under real message volume.
|
// reported slowness under real message volume.
|
||||||
private const val SAVE_DEBOUNCE_MS = 2500L
|
private const val SAVE_DEBOUNCE_MS = 2500L
|
||||||
|
|
||||||
|
// WHOIS reply numerics. Each one's params are [ourNick, targetNick, ...], so the target
|
||||||
|
// nick (params[1]) is what ties a reply line back to the /whois that asked for it.
|
||||||
|
// 318 is RPL_ENDOFWHOIS. These only ever come from a WHOIS, so they're shown as whois
|
||||||
|
// output even when no /whois of ours is pending (e.g. one sent through an alias).
|
||||||
|
private val WHOIS_NUMERICS = setOf(
|
||||||
|
"275", "276", "301", "307", "310", "311", "312", "313", "317", "318", "319",
|
||||||
|
"320", "330", "335", "338", "378", "379", "671"
|
||||||
|
)
|
||||||
|
|
||||||
|
// How long after a /whois is sent that a NOTICE from the server or services still counts
|
||||||
|
// as a response to it (e.g. a server-side ignore / caller-id notice), and so is labelled
|
||||||
|
// and grouped under that whois in wIRC main.
|
||||||
|
private const val WHOIS_NOTICE_WINDOW_MS = 10_000L
|
||||||
|
|
||||||
|
// A /whois that never got its 318 (lost reply, server that doesn't send one) stops being
|
||||||
|
// pending after this long, so stray numerics much later aren't attributed to it.
|
||||||
|
private const val WHOIS_PENDING_TIMEOUT_MS = 60_000L
|
||||||
|
|
||||||
|
// Numerics that only make sense inside a channel window and are already reflected there
|
||||||
|
// (353 fills the user list, see handleLine) or carry nothing worth showing on their own -
|
||||||
|
// kept out of wIRC main so joining a channel doesn't flood it.
|
||||||
|
private val CHANNEL_ONLY_NUMERICS = setOf("332", "333", "353", "366")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide singleton. Owns every IrcConnection and every ChannelBuffer, so that all
|
* App-wide singleton. Owns every IrcConnection and every ChannelBuffer, so that all
|
||||||
* windows read from the same source of truth via StateFlow.
|
* windows read from the same source of truth via StateFlow.
|
||||||
@@ -47,8 +73,30 @@ object ConnectionManager {
|
|||||||
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
|
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
|
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
|
||||||
|
|
||||||
|
// Servers that have a live connection (or one that's connecting/auto-reconnecting after
|
||||||
|
// a network drop). A server leaves this set only through disconnect()/disconnectAll(),
|
||||||
|
// and it's what wIRC main's server cards show as Connected / Disconnected.
|
||||||
|
private val _connectedIds = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
val connectedIds: StateFlow<Set<String>> = _connectedIds
|
||||||
|
|
||||||
|
// serverId -> channels we were in when /quit disconnected that server, so the next
|
||||||
|
// connect() rejoins them (and the windows still open for them pick right back up)
|
||||||
|
// instead of only the configured auto-join list.
|
||||||
|
private val channelsToRejoin = ConcurrentHashMap<String, MutableSet<String>>()
|
||||||
|
|
||||||
|
// A /whois we sent and haven't seen the end of yet. headerShown tracks whether its
|
||||||
|
// "WHOIS <nick>" header line is already in wIRC main.
|
||||||
|
private class PendingWhois(val nick: String, val sentAtMillis: Long, var headerShown: Boolean)
|
||||||
|
|
||||||
|
// serverId -> (lowercased target nick -> pending whois). Only ever touched from the
|
||||||
|
// bufferOps consumer coroutine (see enqueue), so a plain map is enough.
|
||||||
|
private val pendingWhois = HashMap<String, MutableMap<String, PendingWhois>>()
|
||||||
|
|
||||||
fun init(rootDir: File) {
|
fun init(rootDir: File) {
|
||||||
historyRepo = MessageHistoryRepository(rootDir)
|
historyRepo = MessageHistoryRepository(rootDir)
|
||||||
|
// Seeds wIRC main from its saved history right away, so its scrollback is there at
|
||||||
|
// startup instead of appearing only once the first server line arrives.
|
||||||
|
enqueue { ensureBuffer(MAIN_BUFFER_KEY) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setOwnNick(serverId: String, nick: String) {
|
private fun setOwnNick(serverId: String, nick: String) {
|
||||||
@@ -56,11 +104,12 @@ object ConnectionManager {
|
|||||||
_ownNicks.update { it + (serverId to nick) }
|
_ownNicks.update { it + (serverId to nick) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fires once for every brand-new private-message buffer (a query with someone
|
// Fires for every private message to or from a real user (services and the server go
|
||||||
// who wasn't already an open window). Main.kt listens to this to auto-open a
|
// to wIRC main instead). Main.kt adds the key to its set of open windows, which opens
|
||||||
// window for it, same as clicking a channel manually.
|
// the query window if it isn't open - including reopening one the user closed - and
|
||||||
private val _newQueryBuffers = MutableSharedFlow<BufferKey>(extraBufferCapacity = 16)
|
// is a no-op if it already is.
|
||||||
val newQueryBuffers: SharedFlow<BufferKey> = _newQueryBuffers
|
private val _queryWindowRequests = MutableSharedFlow<BufferKey>(extraBufferCapacity = 64)
|
||||||
|
val queryWindowRequests: SharedFlow<BufferKey> = _queryWindowRequests
|
||||||
|
|
||||||
// Every buffer state change - incoming lines, local echoes of our own sends - goes
|
// Every buffer state change - incoming lines, local echoes of our own sends - goes
|
||||||
// through this single FIFO queue, processed by one consumer coroutine. Previously each
|
// through this single FIFO queue, processed by one consumer coroutine. Previously each
|
||||||
@@ -92,10 +141,6 @@ object ConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun connect(config: ServerConfig) {
|
fun connect(config: ServerConfig) {
|
||||||
// TEMP DEBUG (see task: on-connect identify command investigation) - confirms
|
|
||||||
// whether the config this connect() call actually received has the on-connect
|
|
||||||
// commands the caller thinks it saved.
|
|
||||||
println("[connmgr-debug] connect(${config.id}) called - onConnectCommands=${config.onConnectCommands}")
|
|
||||||
val existing = connections[config.id]
|
val existing = connections[config.id]
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
// This used to just `return` here, making connect() a permanent no-op for the
|
// This used to just `return` here, making connect() a permanent no-op for the
|
||||||
@@ -105,48 +150,131 @@ object ConnectionManager {
|
|||||||
// `config` was captured once at construction and never replaced. Tearing down
|
// `config` was captured once at construction and never replaced. Tearing down
|
||||||
// the old connection and replacing it means a connect() call always actually
|
// the old connection and replacing it means a connect() call always actually
|
||||||
// uses whatever was most recently saved.
|
// uses whatever was most recently saved.
|
||||||
println("[connmgr-debug] connect(${config.id}) - already connected, replacing with a fresh connection using the latest config")
|
|
||||||
existing.disconnect()
|
existing.disconnect()
|
||||||
}
|
}
|
||||||
setOwnNick(config.id, config.nick)
|
setOwnNick(config.id, config.nick)
|
||||||
val conn = IrcConnection(
|
// Channels from the connection a /quit ended, or from the one being replaced, so
|
||||||
|
// they're rejoined along with the auto-join list.
|
||||||
|
val rejoin = channelsToRejoin.remove(config.id).orEmpty() + existing?.trackedChannels().orEmpty()
|
||||||
|
lateinit var conn: IrcConnection
|
||||||
|
conn = IrcConnection(
|
||||||
config = config,
|
config = config,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
onLine = { line -> enqueue { handleLine(config.id, line) } },
|
initialChannels = rejoin,
|
||||||
onStatus = { msg -> _statuses.update { it + (config.id to msg) } }
|
// Lines and status updates from a connection that's already been disconnected
|
||||||
|
// or replaced (its socket closing, a last line in flight) are dropped, so
|
||||||
|
// nothing shows up after the "Disconnected" line or overwrites that status.
|
||||||
|
onLine = { line -> if (connections[config.id] === conn) enqueue { handleLine(config.id, line) } },
|
||||||
|
onStatus = { msg ->
|
||||||
|
if (connections[config.id] === conn) {
|
||||||
|
_statuses.update { it + (config.id to msg) }
|
||||||
|
// Connection progress and errors aren't tied to any channel - wIRC main.
|
||||||
|
enqueue { addMainLine(MessageKind.SYSTEM, null, "[${config.label}] $msg") }
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
connections[config.id] = conn
|
connections[config.id] = conn
|
||||||
|
_connectedIds.update { it + config.id }
|
||||||
conn.connect()
|
conn.connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bare /quit: disconnects this one server and nothing else. Windows stay open and keep
|
||||||
|
* their scrollback; every buffer of this server (plus [openKeys], the open windows, in
|
||||||
|
* case one hasn't had a line yet) gets a "Disconnected" line, and so does wIRC main.
|
||||||
|
* There's no auto-reconnect afterward - the next connect() (the server card's Connect
|
||||||
|
* button) rejoins the channels we were in. History is written to disk right away, the
|
||||||
|
* same as disconnectAll() does on exit.
|
||||||
|
*/
|
||||||
|
fun disconnect(serverId: String, openKeys: Collection<BufferKey> = emptyList()) {
|
||||||
|
val conn = connections.remove(serverId) ?: return
|
||||||
|
conn.disconnect()
|
||||||
|
channelsToRejoin[serverId] = conn.trackedChannels().toMutableSet()
|
||||||
|
_connectedIds.update { it - serverId }
|
||||||
|
_statuses.update { it + (serverId to "Disconnected") }
|
||||||
|
val label = conn.config.label
|
||||||
|
enqueue {
|
||||||
|
pendingWhois.remove(serverId)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val keys = _buffers.value.keys.filter { it.serverId == serverId } +
|
||||||
|
openKeys.filter { it.serverId == serverId }
|
||||||
|
keys.distinct().forEach { key ->
|
||||||
|
addMessage(key, IrcMessage(now, MessageKind.SYSTEM, null, "Disconnected from $label"))
|
||||||
|
}
|
||||||
|
// We're no longer in any channel, so the user lists are stale - the NAMES reply
|
||||||
|
// on rejoin fills them again.
|
||||||
|
_buffers.update { map ->
|
||||||
|
map.mapValues { (key, buf) -> if (key.serverId == serverId) buf.copy(users = emptyList()) else buf }
|
||||||
|
}
|
||||||
|
addMainLine(MessageKind.SYSTEM, null, "[$label] Disconnected")
|
||||||
|
// Runs on the bufferOps queue after the lines above, so they're in the save.
|
||||||
|
flushAllSaves()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun disconnectAll() {
|
fun disconnectAll() {
|
||||||
connections.values.forEach { it.disconnect() }
|
connections.values.forEach { it.disconnect() }
|
||||||
connections.clear()
|
connections.clear()
|
||||||
|
_connectedIds.value = emptySet()
|
||||||
flushAllSaves()
|
flushAllSaves()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isConnected(serverId: String): Boolean = connections.containsKey(serverId)
|
||||||
|
|
||||||
|
// /part from a channel window. While disconnected there's no PART to send, but the
|
||||||
|
// channel is dropped from the rejoin list so reconnecting doesn't rejoin it.
|
||||||
|
fun partChannel(serverId: String, channel: String, reason: String) {
|
||||||
|
val conn = connectionFor(serverId)
|
||||||
|
if (conn != null) conn.partChannel(channel, reason)
|
||||||
|
else channelsToRejoin[serverId]?.remove(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// /join from a channel window. A plain channel list is tracked (see
|
||||||
|
// IrcConnection.joinChannel) so a reconnect rejoins it; one with a key is sent as-is,
|
||||||
|
// since rejoining it later without the key wouldn't work anyway.
|
||||||
|
fun joinChannels(serverId: String, args: String) {
|
||||||
|
val conn = connectionFor(serverId) ?: return
|
||||||
|
if (args.contains(' ')) conn.send("JOIN $args")
|
||||||
|
else args.split(',').filter { it.isNotBlank() }.forEach { conn.joinChannel(it) }
|
||||||
|
}
|
||||||
|
|
||||||
fun connectionFor(serverId: String): IrcConnection? = connections[serverId]
|
fun connectionFor(serverId: String): IrcConnection? = connections[serverId]
|
||||||
|
|
||||||
// IRC servers don't echo PRIVMSG back to its sender, so a sent message has to be
|
// IRC servers don't echo PRIVMSG back to its sender, so a sent message has to be
|
||||||
// added to our own buffer locally - otherwise it would never show up in the window
|
// added to our own buffer locally - otherwise it would never show up in the window
|
||||||
// that sent it.
|
// that sent it.
|
||||||
fun sendChannelMessage(serverId: String, target: String, text: String) {
|
fun sendChannelMessage(serverId: String, target: String, text: String) {
|
||||||
connectionFor(serverId)?.sendMessage(target, text)
|
// No local echo while disconnected - the line was never sent.
|
||||||
|
val conn = connectionFor(serverId) ?: return
|
||||||
|
conn.sendMessage(target, text)
|
||||||
val myNick = ownNicks[serverId] ?: return
|
val myNick = ownNicks[serverId] ?: return
|
||||||
val key = BufferKey(serverId, target)
|
|
||||||
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
|
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
|
||||||
enqueue { addMessage(key, msg) }
|
enqueue { addOutgoing(serverId, target, msg, "-> *$target* $text") }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same local-echo problem as sendChannelMessage, but for /me: the wire form is a
|
// Same local-echo problem as sendChannelMessage, but for /me: the wire form is a
|
||||||
// CTCP ACTION wrapper, while the buffer should just hold the plain action text
|
// CTCP ACTION wrapper, while the buffer should just hold the plain action text
|
||||||
// (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix).
|
// (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix).
|
||||||
fun sendChannelAction(serverId: String, target: String, action: String) {
|
fun sendChannelAction(serverId: String, target: String, action: String) {
|
||||||
connectionFor(serverId)?.sendMessage(target, "ACTION $action")
|
val conn = connectionFor(serverId) ?: return
|
||||||
|
conn.sendMessage(target, "\u0001ACTION $action\u0001")
|
||||||
val myNick = ownNicks[serverId] ?: return
|
val myNick = ownNicks[serverId] ?: return
|
||||||
val key = BufferKey(serverId, target)
|
|
||||||
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
|
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
|
||||||
enqueue { addMessage(key, msg) }
|
enqueue { addOutgoing(serverId, target, msg, "-> *$target* * $myNick $action") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where a local echo goes: a channel's own buffer, wIRC main for a service (e.g.
|
||||||
|
// "/msg NickServ ..."), or a query buffer - opening its window - for a real user.
|
||||||
|
private fun addOutgoing(serverId: String, target: String, msg: IrcMessage, mainText: String) {
|
||||||
|
when {
|
||||||
|
isChannelName(target) -> addMessage(BufferKey(serverId, target), msg)
|
||||||
|
isServiceNick(target) -> addMainLine(MessageKind.SYSTEM, null, mainText)
|
||||||
|
else -> {
|
||||||
|
val key = BufferKey(serverId, target)
|
||||||
|
addMessage(key, msg)
|
||||||
|
_queryWindowRequests.tryEmit(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw passthrough for slash commands that don't have dedicated handling.
|
// Raw passthrough for slash commands that don't have dedicated handling.
|
||||||
@@ -154,6 +282,71 @@ object ConnectionManager {
|
|||||||
connectionFor(serverId)?.send(raw)
|
connectionFor(serverId)?.send(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends WHOIS. All of its output - the header, every reply line, and any server or
|
||||||
|
* services notice that comes back in response - goes to wIRC main, whichever window the
|
||||||
|
* command was typed in. [args] is whatever followed "/whois"; its last word is the nick
|
||||||
|
* being looked up (the "/whois server nick" form puts the nick last too), and may be a
|
||||||
|
* comma-separated list.
|
||||||
|
*/
|
||||||
|
fun whois(serverId: String, args: String) {
|
||||||
|
val label = connectionFor(serverId)?.config?.label
|
||||||
|
val nicks = args.trim().split(Regex("\\s+")).lastOrNull()
|
||||||
|
?.split(',')?.filter { it.isNotEmpty() }.orEmpty()
|
||||||
|
if (nicks.isEmpty() || label == null) {
|
||||||
|
val text = if (nicks.isEmpty()) "[whois] Usage: /whois <nick>" else "[whois] Not connected to this server"
|
||||||
|
enqueue { addMainLine(MessageKind.SYSTEM, null, text) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Enqueued before the WHOIS goes out, so the header is always in the buffer ahead
|
||||||
|
// of any reply (replies come in through the same queue, and can't arrive before
|
||||||
|
// the command is sent). Each further nick of a comma list gets its header when its
|
||||||
|
// first reply line arrives, so the lines end up grouped under the right one.
|
||||||
|
enqueue {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val pending = pendingWhois.getOrPut(serverId) { HashMap() }
|
||||||
|
nicks.forEach { pending[it.lowercase()] = PendingWhois(it, now, headerShown = false) }
|
||||||
|
showWhoisHeader(serverId, pending.getValue(nicks.first().lowercase()))
|
||||||
|
}
|
||||||
|
sendRaw(serverId, "WHOIS ${args.trim()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showWhoisHeader(serverId: String, pending: PendingWhois) {
|
||||||
|
if (pending.headerShown) return
|
||||||
|
pending.headerShown = true
|
||||||
|
val label = connectionFor(serverId)?.config?.label ?: serverId
|
||||||
|
// \u0002 is the mIRC bold toggle, so the header stands out from the reply lines.
|
||||||
|
addMainLine(MessageKind.SYSTEM, null, "\u0002===== WHOIS ${pending.nick} ($label) =====\u0002")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending whois for this server, with any that timed out dropped first.
|
||||||
|
private fun livePendingWhois(serverId: String): MutableMap<String, PendingWhois>? {
|
||||||
|
val pending = pendingWhois[serverId] ?: return null
|
||||||
|
val cutoff = System.currentTimeMillis() - WHOIS_PENDING_TIMEOUT_MS
|
||||||
|
pending.values.removeAll { it.sentAtMillis < cutoff }
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
// A numeric is whois output if it's one of WHOIS_NUMERICS, or if it names a nick we
|
||||||
|
// have a /whois pending for (401/402 "no such nick/server", 716 "is in +g mode
|
||||||
|
// (server-side ignore)", ...), or if it's about the WHOIS command itself (263
|
||||||
|
// "try again", 481, ...) while one is pending.
|
||||||
|
private fun isWhoisNumeric(serverId: String, line: ParsedLine): Boolean {
|
||||||
|
if (line.command in WHOIS_NUMERICS) return true
|
||||||
|
if (!line.command.all { it.isDigit() }) return false
|
||||||
|
val pending = livePendingWhois(serverId)
|
||||||
|
if (pending.isNullOrEmpty()) return false
|
||||||
|
val subject = line.params.getOrNull(1) ?: return false
|
||||||
|
return subject.lowercase() in pending || subject.equals("WHOIS", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The most recently sent /whois on this server, if it went out recently enough that a
|
||||||
|
// server or services NOTICE arriving now is plausibly a response to it.
|
||||||
|
private fun whoisAwaitingNotice(serverId: String): PendingWhois? {
|
||||||
|
val recent = livePendingWhois(serverId)?.values?.maxByOrNull { it.sentAtMillis } ?: return null
|
||||||
|
return recent.takeIf { System.currentTimeMillis() - it.sentAtMillis <= WHOIS_NOTICE_WINDOW_MS }
|
||||||
|
}
|
||||||
|
|
||||||
// Local-only feedback (e.g. the /reload confirmation) that never touches the
|
// Local-only feedback (e.g. the /reload confirmation) that never touches the
|
||||||
// wire - just appended to the buffer the same way any other SYSTEM line is.
|
// wire - just appended to the buffer the same way any other SYSTEM line is.
|
||||||
fun addSystemMessage(serverId: String, target: String, text: String) {
|
fun addSystemMessage(serverId: String, target: String, text: String) {
|
||||||
@@ -177,12 +370,6 @@ object ConnectionManager {
|
|||||||
if (_buffers.value.containsKey(key)) return
|
if (_buffers.value.containsKey(key)) return
|
||||||
val seeded = historyRepo?.loadMessages(key) ?: emptyList()
|
val seeded = historyRepo?.loadMessages(key) ?: emptyList()
|
||||||
_buffers.update { map -> map + (key to ChannelBuffer(key, messages = seeded)) }
|
_buffers.update { map -> map + (key to ChannelBuffer(key, messages = seeded)) }
|
||||||
if (!isChannelName(key.name)) {
|
|
||||||
// tryEmit, not emit: this runs on the single, non-suspend bufferOps consumer,
|
|
||||||
// not its own coroutine, so there's nothing to suspend into. The SharedFlow's
|
|
||||||
// 16-slot extraBufferCapacity means this can't actually fail in practice.
|
|
||||||
_newQueryBuffers.tryEmit(key)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addMessage(key: BufferKey, msg: IrcMessage) {
|
private fun addMessage(key: BufferKey, msg: IrcMessage) {
|
||||||
@@ -195,6 +382,10 @@ object ConnectionManager {
|
|||||||
scheduleSave(key)
|
scheduleSave(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun addMainLine(kind: MessageKind, sender: String?, text: String) {
|
||||||
|
addMessage(MAIN_BUFFER_KEY, IrcMessage(System.currentTimeMillis(), kind, sender, text))
|
||||||
|
}
|
||||||
|
|
||||||
// Coalesces rapid-fire saves for the same buffer into at most one write every
|
// Coalesces rapid-fire saves for the same buffer into at most one write every
|
||||||
// SAVE_DEBOUNCE_MS, instead of a full history save (up to 500 messages) on every single
|
// SAVE_DEBOUNCE_MS, instead of a full history save (up to 500 messages) on every single
|
||||||
// incoming message - the direct cause of reported slowness in a busy channel. Reads
|
// incoming message - the direct cause of reported slowness in a busy channel. Reads
|
||||||
@@ -229,6 +420,67 @@ object ConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every WHOIS reply line goes to wIRC main, under its "WHOIS <nick>" header. A reply
|
||||||
|
// with no pending /whois (one sent through an alias or a raw command) gets a header
|
||||||
|
// of its own the same way, so every whois result in wIRC main starts with one.
|
||||||
|
private fun handleWhoisLine(serverId: String, line: ParsedLine) {
|
||||||
|
val target = line.params.getOrNull(1) ?: return
|
||||||
|
val pending = livePendingWhois(serverId)
|
||||||
|
val entry = pending?.get(target.lowercase())
|
||||||
|
?: if (target.equals("WHOIS", ignoreCase = true)) pending?.values?.maxByOrNull { it.sentAtMillis } else null
|
||||||
|
when {
|
||||||
|
entry != null -> showWhoisHeader(serverId, entry)
|
||||||
|
line.command in WHOIS_NUMERICS && line.command != "318" -> {
|
||||||
|
val unsolicited = PendingWhois(target, System.currentTimeMillis(), headerShown = false)
|
||||||
|
pendingWhois.getOrPut(serverId) { HashMap() }[target.lowercase()] = unsolicited
|
||||||
|
showWhoisHeader(serverId, unsolicited)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line.command == "318") {
|
||||||
|
// A comma-list whois ends with a single 318 naming the whole list.
|
||||||
|
target.split(',').forEach { pending?.remove(it.lowercase()) }
|
||||||
|
}
|
||||||
|
addMainLine(MessageKind.SYSTEM, null, "[whois] " + formatWhoisLine(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatWhoisLine(line: ParsedLine): String {
|
||||||
|
val p = line.params
|
||||||
|
val nick = p.getOrNull(1) ?: ""
|
||||||
|
return when (line.command) {
|
||||||
|
"311" -> "$nick is ${p.getOrNull(2)}@${p.getOrNull(3)} (${p.lastOrNull() ?: ""})"
|
||||||
|
"312" -> "$nick is on ${p.getOrNull(2)} (${p.lastOrNull() ?: ""})"
|
||||||
|
"317" -> {
|
||||||
|
val idleSecs = p.getOrNull(2)?.toLongOrNull()
|
||||||
|
val signon = p.getOrNull(3)?.toLongOrNull()
|
||||||
|
buildString {
|
||||||
|
append("$nick has been idle ${idleSecs?.let { formatDuration(it) } ?: "?"}")
|
||||||
|
if (signon != null) {
|
||||||
|
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
|
||||||
|
append(", signed on ${fmt.format(Date(signon * 1000))}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"319" -> "$nick is on channels: ${p.lastOrNull() ?: ""}"
|
||||||
|
"330" -> "$nick ${p.lastOrNull() ?: "is logged in as"} ${p.getOrNull(2) ?: ""}".trim()
|
||||||
|
"301" -> "$nick is away: ${p.lastOrNull() ?: ""}"
|
||||||
|
"318" -> "End of WHOIS for $nick"
|
||||||
|
else -> p.drop(1).joinToString(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDuration(totalSecs: Long): String {
|
||||||
|
val d = totalSecs / 86400
|
||||||
|
val h = (totalSecs % 86400) / 3600
|
||||||
|
val m = (totalSecs % 3600) / 60
|
||||||
|
val s = totalSecs % 60
|
||||||
|
return buildString {
|
||||||
|
if (d > 0) append("${d}d ")
|
||||||
|
if (d > 0 || h > 0) append("${h}h ")
|
||||||
|
if (d > 0 || h > 0 || m > 0) append("${m}m ")
|
||||||
|
append("${s}s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleLine(serverId: String, line: ParsedLine) {
|
private fun handleLine(serverId: String, line: ParsedLine) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val nick = nickFromPrefix(line.prefix) ?: line.prefix
|
val nick = nickFromPrefix(line.prefix) ?: line.prefix
|
||||||
@@ -237,16 +489,51 @@ object ConnectionManager {
|
|||||||
"PRIVMSG" -> {
|
"PRIVMSG" -> {
|
||||||
val target = line.params.getOrNull(0) ?: return
|
val target = line.params.getOrNull(0) ?: return
|
||||||
val text = line.params.getOrNull(1) ?: return
|
val text = line.params.getOrNull(1) ?: return
|
||||||
val isAction = text.startsWith("ACTION") && text.endsWith("")
|
val isCtcp = text.length > 1 && text.startsWith("\u0001") && text.endsWith("\u0001")
|
||||||
val body = if (isAction) text.removePrefix("ACTION ").removeSuffix("") else text
|
val isAction = isCtcp && text.startsWith("\u0001ACTION")
|
||||||
// Channel messages are keyed by the channel name. Private messages arrive
|
val body = if (isAction) text.removePrefix("\u0001ACTION ").removeSuffix("\u0001") else text
|
||||||
// with target == our own nick, so key those by the *sender* instead -
|
if (isChannelName(target)) {
|
||||||
// that's the conversation identity a query window should use.
|
addMessage(
|
||||||
val bufferName = if (isChannelName(target)) target else (nick ?: target)
|
BufferKey(serverId, target),
|
||||||
addMessage(
|
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
|
||||||
BufferKey(serverId, bufferName),
|
)
|
||||||
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
|
} else if (isServiceOrServerPrefix(line.prefix) || (isCtcp && !isAction)) {
|
||||||
)
|
// Services, the server itself, and CTCP requests other than ACTION
|
||||||
|
// (PING, TIME, ...) go to wIRC main instead of opening a query window.
|
||||||
|
// (VERSION is already answered and swallowed in IrcConnection.)
|
||||||
|
val shown = if (isCtcp && !isAction) "[CTCP ${text.trim('\u0001')} from $nick]" else body
|
||||||
|
addMainLine(MessageKind.CHAT, nick, shown)
|
||||||
|
} else {
|
||||||
|
// A private message from a real user arrives with target == our own
|
||||||
|
// nick, so key it by the *sender* - that's the conversation identity a
|
||||||
|
// query window should use - and (re)open that window.
|
||||||
|
val key = BufferKey(serverId, nick ?: target)
|
||||||
|
addMessage(key, IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body))
|
||||||
|
_queryWindowRequests.tryEmit(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"NOTICE" -> {
|
||||||
|
val target = line.params.getOrNull(0) ?: return
|
||||||
|
val text = line.params.getOrNull(1) ?: return
|
||||||
|
if (isChannelName(target)) {
|
||||||
|
addMessage(BufferKey(serverId, target), IrcMessage(now, MessageKind.NOTICE, nick, text))
|
||||||
|
} else if (isServiceOrServerPrefix(line.prefix)) {
|
||||||
|
// NickServ/ChanServ replies (identify success/failure etc.) and server
|
||||||
|
// notices - wIRC main. The sender goes into the text because the
|
||||||
|
// NOTICE row layout doesn't show a nick column. One arriving right
|
||||||
|
// after a /whois (e.g. a server-side ignore notice) is labelled as part
|
||||||
|
// of that whois and goes under its header.
|
||||||
|
val whois = whoisAwaitingNotice(serverId)
|
||||||
|
if (whois != null) showWhoisHeader(serverId, whois)
|
||||||
|
val label = if (whois != null) "[whois] " else ""
|
||||||
|
addMainLine(MessageKind.NOTICE, nick, "$label-${nick ?: "server"}- $text")
|
||||||
|
} else {
|
||||||
|
// A notice directed AT us arrives with target == our own nick, so key
|
||||||
|
// it by the sender, same as a private message.
|
||||||
|
val key = BufferKey(serverId, nick ?: target)
|
||||||
|
addMessage(key, IrcMessage(now, MessageKind.NOTICE, nick, text))
|
||||||
|
_queryWindowRequests.tryEmit(key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"JOIN" -> {
|
"JOIN" -> {
|
||||||
val channel = line.params.getOrNull(0) ?: return
|
val channel = line.params.getOrNull(0) ?: return
|
||||||
@@ -270,9 +557,13 @@ object ConnectionManager {
|
|||||||
}
|
}
|
||||||
"MODE" -> {
|
"MODE" -> {
|
||||||
val channel = line.params.getOrNull(0) ?: return
|
val channel = line.params.getOrNull(0) ?: return
|
||||||
if (!channel.startsWith("#")) return
|
|
||||||
val modeStr = line.params.getOrNull(1) ?: ""
|
val modeStr = line.params.getOrNull(1) ?: ""
|
||||||
val modeTarget = line.params.getOrNull(2)
|
val modeTarget = line.params.getOrNull(2)
|
||||||
|
if (!isChannelName(channel)) {
|
||||||
|
// User mode on our own nick (e.g. +i, +r after identifying) - wIRC main.
|
||||||
|
addMainLine(MessageKind.MODE, nick, "$nick sets mode $modeStr on $channel")
|
||||||
|
return
|
||||||
|
}
|
||||||
addMessage(
|
addMessage(
|
||||||
BufferKey(serverId, channel),
|
BufferKey(serverId, channel),
|
||||||
IrcMessage(now, MessageKind.MODE, nick, "$nick sets mode $modeStr ${modeTarget ?: ""}".trim())
|
IrcMessage(now, MessageKind.MODE, nick, "$nick sets mode $modeStr ${modeTarget ?: ""}".trim())
|
||||||
@@ -315,20 +606,6 @@ object ConnectionManager {
|
|||||||
}
|
}
|
||||||
updateUsers(BufferKey(serverId, channel)) { existing -> (existing + parsedUsers).distinctBy { it.nick } }
|
updateUsers(BufferKey(serverId, channel)) { existing -> (existing + parsedUsers).distinctBy { it.nick } }
|
||||||
}
|
}
|
||||||
"NOTICE" -> {
|
|
||||||
val target = line.params.getOrNull(0) ?: return
|
|
||||||
val text = line.params.getOrNull(1) ?: return
|
|
||||||
// Same fix PRIVMSG already had: a notice directed AT us arrives with
|
|
||||||
// target == our own nick, not the sender. This mattered a lot in practice -
|
|
||||||
// NickServ/ChanServ almost universally reply to commands (IDENTIFY, etc.)
|
|
||||||
// via NOTICE rather than PRIVMSG, so without this fallback every identify
|
|
||||||
// success/failure notice was being filed under a buffer keyed by our OWN
|
|
||||||
// nick - which no window ever opens for - instead of under "NickServ",
|
|
||||||
// making it look like the automatic identify produced no feedback at all,
|
|
||||||
// whether or not it actually worked.
|
|
||||||
val bufferName = if (isChannelName(target)) target else (nick ?: target)
|
|
||||||
addMessage(BufferKey(serverId, bufferName), IrcMessage(now, MessageKind.NOTICE, nick, text))
|
|
||||||
}
|
|
||||||
"NICK" -> {
|
"NICK" -> {
|
||||||
val oldNick = nick ?: return
|
val oldNick = nick ?: return
|
||||||
val newNick = line.params.getOrNull(0) ?: return
|
val newNick = line.params.getOrNull(0) ?: return
|
||||||
@@ -347,6 +624,7 @@ object ConnectionManager {
|
|||||||
}
|
}
|
||||||
if (ownNicks[serverId] == oldNick) {
|
if (ownNicks[serverId] == oldNick) {
|
||||||
setOwnNick(serverId, newNick)
|
setOwnNick(serverId, newNick)
|
||||||
|
addMainLine(MessageKind.NICK, null, "You are now known as $newNick")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"QUIT" -> {
|
"QUIT" -> {
|
||||||
@@ -360,6 +638,27 @@ object ConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"INVITE" -> {
|
||||||
|
val channel = line.params.getOrNull(1) ?: return
|
||||||
|
addMainLine(MessageKind.SYSTEM, nick, "$nick invited you to $channel")
|
||||||
|
}
|
||||||
|
"ERROR" -> {
|
||||||
|
addMainLine(MessageKind.SYSTEM, null, "ERROR: ${line.params.joinToString(" ")}")
|
||||||
|
}
|
||||||
|
"PONG", "CAP" -> Unit
|
||||||
|
else -> {
|
||||||
|
when {
|
||||||
|
isWhoisNumeric(serverId, line) -> handleWhoisLine(serverId, line)
|
||||||
|
line.command in CHANNEL_ONLY_NUMERICS -> Unit
|
||||||
|
// Every other numeric (welcome, ISUPPORT, LUSERS, MOTD, errors, ...) is a
|
||||||
|
// server message; params[0] is just our own nick, so it's dropped.
|
||||||
|
line.command.all { it.isDigit() } ->
|
||||||
|
addMainLine(MessageKind.SYSTEM, line.prefix, line.params.drop(1).joinToString(" "))
|
||||||
|
// Anything else not tied to a channel (WALLOPS, KILL, ...) - wIRC main as-is.
|
||||||
|
else ->
|
||||||
|
addMainLine(MessageKind.SYSTEM, nick, "${line.command} ${line.params.joinToString(" ")}".trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ private fun translateOnConnectCommand(cmd: String): String {
|
|||||||
class IrcConnection(
|
class IrcConnection(
|
||||||
val config: ServerConfig,
|
val config: ServerConfig,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
|
// Channels to rejoin on top of config.autoJoinChannels - the ones a previous connection
|
||||||
|
// to this server was in (see ConnectionManager.connect).
|
||||||
|
initialChannels: Collection<String> = emptyList(),
|
||||||
private val onLine: (ParsedLine) -> Unit,
|
private val onLine: (ParsedLine) -> Unit,
|
||||||
private val onStatus: (String) -> Unit
|
private val onStatus: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -60,13 +63,17 @@ class IrcConnection(
|
|||||||
@Volatile private var socket: Socket? = null
|
@Volatile private var socket: Socket? = null
|
||||||
@Volatile private var writer: PrintWriter? = null
|
@Volatile private var writer: PrintWriter? = null
|
||||||
private var readJob: Job? = null
|
private var readJob: Job? = null
|
||||||
private var wantConnected = false
|
// @Volatile: disconnect() clears it from the UI thread while the read loop checks it on
|
||||||
|
// an IO thread - it's what stops the loop from auto-reconnecting after a /quit.
|
||||||
|
@Volatile private var wantConnected = false
|
||||||
private var backoffMillis = 2000L
|
private var backoffMillis = 2000L
|
||||||
|
|
||||||
// Every channel we're currently in, whether from config.autoJoinChannels or joined
|
// Every channel we're currently in, whether from config.autoJoinChannels or joined
|
||||||
// manually mid-session - so a reconnect rejoins everything, not just the original
|
// manually mid-session - so a reconnect rejoins everything, not just the original
|
||||||
// auto-join list.
|
// auto-join list.
|
||||||
private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet<String>()
|
private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet<String>(initialChannels)
|
||||||
|
|
||||||
|
fun trackedChannels(): Set<String> = joinedChannels.toSet()
|
||||||
|
|
||||||
// Guards runPostRegistration() against running twice for the same connection cycle
|
// Guards runPostRegistration() against running twice for the same connection cycle
|
||||||
// (376 and 422 are mutually exclusive per RFC, but this is cheap insurance) - reset
|
// (376 and 422 are mutually exclusive per RFC, but this is cheap insurance) - reset
|
||||||
@@ -121,6 +128,9 @@ class IrcConnection(
|
|||||||
Socket(config.host, config.port)
|
Socket(config.host, config.port)
|
||||||
}
|
}
|
||||||
socket = sock
|
socket = sock
|
||||||
|
// disconnect() can't interrupt a socket that's still being opened, so
|
||||||
|
// check here before registering on a connection nobody wants anymore.
|
||||||
|
ensureActive()
|
||||||
val out = PrintWriter(OutputStreamWriter(sock.getOutputStream(), Charsets.UTF_8), true)
|
val out = PrintWriter(OutputStreamWriter(sock.getOutputStream(), Charsets.UTF_8), true)
|
||||||
writer = out
|
writer = out
|
||||||
|
|
||||||
@@ -161,6 +171,9 @@ class IrcConnection(
|
|||||||
handleRawLine(line)
|
handleRawLine(line)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
// After disconnect() the read fails because we closed the socket
|
||||||
|
// ourselves - that's not an error worth reporting.
|
||||||
|
if (!wantConnected) break
|
||||||
onStatus("Connection error: ${e.message}")
|
onStatus("Connection error: ${e.message}")
|
||||||
ircDebug(config.id, "connection dropped: ${e.message} - tracked channels at drop time: $joinedChannels")
|
ircDebug(config.id, "connection dropped: ${e.message} - tracked channels at drop time: $joinedChannels")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -277,6 +290,9 @@ class IrcConnection(
|
|||||||
try { writer?.println("QUIT :leaving") } catch (_: Exception) {}
|
try { writer?.println("QUIT :leaving") } catch (_: Exception) {}
|
||||||
readJob?.cancel()
|
readJob?.cancel()
|
||||||
closeSocketQuietly()
|
closeSocketQuietly()
|
||||||
|
// Ends the outbox consumer - this connection is never reused (a reconnect
|
||||||
|
// makes a new IrcConnection), so it would otherwise idle forever.
|
||||||
|
outbox.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.platform.Font
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fonts offered in each window's settings. Stored in window_prefs.json by [id], so the ids
|
||||||
|
* must never change once shipped.
|
||||||
|
*
|
||||||
|
* Two kinds:
|
||||||
|
* - Windows fonts (Segoe UI, Tahoma, Tw Cen MT) are Microsoft-licensed, so they are NOT
|
||||||
|
* bundled. They are loaded straight out of the Windows font folders at runtime and only
|
||||||
|
* listed when the file is actually there (Tw Cen MT only exists if Office is installed).
|
||||||
|
* - Rounded fonts (Nunito, Quicksand, Varela Round) are SIL OFL licensed and bundled in
|
||||||
|
* src/main/resources/fonts with their license files.
|
||||||
|
*
|
||||||
|
* Anything missing or failing to load falls back to Monospace, so a font uninstalled after
|
||||||
|
* being picked can never crash a window.
|
||||||
|
*/
|
||||||
|
data class FontOption(val id: String, val label: String, val family: FontFamily)
|
||||||
|
|
||||||
|
object AppFonts {
|
||||||
|
const val DEFAULT_ID = "monospace"
|
||||||
|
|
||||||
|
private val fontDirs: List<File> = buildList {
|
||||||
|
add(File(System.getenv("WINDIR") ?: "C:\\Windows", "Fonts"))
|
||||||
|
System.getenv("LOCALAPPDATA")?.let { add(File(it, "Microsoft\\Windows\\Fonts")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive lookup across the system and per-user font folders. */
|
||||||
|
private fun findSystemFont(name: String): File? {
|
||||||
|
for (dir in fontDirs) {
|
||||||
|
val direct = File(dir, name)
|
||||||
|
if (direct.isFile) return direct
|
||||||
|
dir.listFiles()?.firstOrNull { it.name.equals(name, ignoreCase = true) }?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun systemFamily(regular: String, bold: String?): FontFamily? {
|
||||||
|
val reg = findSystemFont(regular) ?: return null
|
||||||
|
return try {
|
||||||
|
val fonts = mutableListOf(Font(reg, FontWeight.Normal))
|
||||||
|
bold?.let { findSystemFont(it) }?.let { fonts.add(Font(it, FontWeight.Bold)) }
|
||||||
|
FontFamily(fonts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bundledFamily(regular: String, bold: String?): FontFamily? = try {
|
||||||
|
val fonts = mutableListOf(Font("fonts/$regular", FontWeight.Normal))
|
||||||
|
bold?.let { fonts.add(Font("fonts/$it", FontWeight.Bold)) }
|
||||||
|
FontFamily(fonts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Built once at startup; the font folders don't change while wIRC is running. */
|
||||||
|
val options: List<FontOption> by lazy {
|
||||||
|
buildList {
|
||||||
|
add(FontOption(DEFAULT_ID, "Monospace", FontFamily.Monospace))
|
||||||
|
systemFamily("segoeui.ttf", "segoeuib.ttf")?.let { add(FontOption("segoe-ui", "Segoe UI", it)) }
|
||||||
|
systemFamily("tahoma.ttf", "tahomabd.ttf")?.let { add(FontOption("tahoma", "Tahoma", it)) }
|
||||||
|
systemFamily("TCM_____.TTF", "TCB_____.TTF")?.let { add(FontOption("tw-cen-mt", "Tw Cen MT", it)) }
|
||||||
|
bundledFamily("Nunito-Regular.ttf", "Nunito-Bold.ttf")?.let { add(FontOption("nunito", "Nunito (rounded)", it)) }
|
||||||
|
bundledFamily("Quicksand-Regular.ttf", "Quicksand-Bold.ttf")?.let { add(FontOption("quicksand", "Quicksand (rounded)", it)) }
|
||||||
|
bundledFamily("VarelaRound-Regular.ttf", null)?.let { add(FontOption("varela-round", "Varela Round (rounded)", it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun familyFor(id: String): FontFamily =
|
||||||
|
options.firstOrNull { it.id == id }?.family ?: FontFamily.Monospace
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The font list used in both the channel-window settings and wIRC main's Font dialog. Each
|
||||||
|
* name is drawn in its own font as a live preview; plain square-cornered rows, no dropdown.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FontPickerList(selectedId: String, textColor: Color, onPick: (String) -> Unit) {
|
||||||
|
val effectiveId = if (AppFonts.options.any { it.id == selectedId }) selectedId else AppFonts.DEFAULT_ID
|
||||||
|
Column {
|
||||||
|
AppFonts.options.forEach { option ->
|
||||||
|
Text(
|
||||||
|
text = option.label,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = option.family,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(if (option.id == effectiveId) Color.White.copy(alpha = 0.18f) else Color.Transparent)
|
||||||
|
.clickable { onPick(option.id) }
|
||||||
|
.padding(horizontal = 6.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+938
-413
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ package ui
|
|||||||
import androidx.compose.material3.ColorScheme
|
import androidx.compose.material3.ColorScheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
@@ -10,8 +11,9 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.rememberWindowState
|
|
||||||
import data.AliasRepository
|
import data.AliasRepository
|
||||||
import data.BufferKey
|
import data.BufferKey
|
||||||
import data.WindowDisplaySettings
|
import data.WindowDisplaySettings
|
||||||
@@ -19,9 +21,20 @@ import data.WindowPrefsRepository
|
|||||||
import data.isChannelName
|
import data.isChannelName
|
||||||
import data.storageId
|
import data.storageId
|
||||||
import irc.ConnectionManager
|
import irc.ConnectionManager
|
||||||
|
import irc.IrcConnection
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private const val NOT_CONNECTED_TEXT = "Not connected - use Connect in wIRC main to reconnect"
|
||||||
|
|
||||||
|
// Runs [action] on this server's connection, or shows the not-connected line in this
|
||||||
|
// window when there isn't one (e.g. the user-list kick/ban/op menu after a /quit).
|
||||||
|
private fun withConnection(serverId: String, windowName: String, action: (IrcConnection) -> Unit) {
|
||||||
|
val conn = ConnectionManager.connectionFor(serverId)
|
||||||
|
if (conn != null) action(conn)
|
||||||
|
else ConnectionManager.addSystemMessage(serverId, windowName, NOT_CONNECTED_TEXT)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One of these is composed per open buffer key (see Main.kt) - each is a real, independent
|
* One of these is composed per open buffer key (see Main.kt) - each is a real, independent
|
||||||
* top-level OS window, same intent as ChannelWindowActivity.kt's per-channel Activity on
|
* top-level OS window, same intent as ChannelWindowActivity.kt's per-channel Activity on
|
||||||
@@ -33,9 +46,11 @@ fun ChannelWindow(
|
|||||||
bufferKey: BufferKey,
|
bufferKey: BufferKey,
|
||||||
prefsRepo: WindowPrefsRepository,
|
prefsRepo: WindowPrefsRepository,
|
||||||
colorScheme: ColorScheme,
|
colorScheme: ColorScheme,
|
||||||
|
visible: Boolean,
|
||||||
|
onHide: () -> Unit,
|
||||||
onOpenBuffer: (BufferKey) -> Unit,
|
onOpenBuffer: (BufferKey) -> Unit,
|
||||||
onCloseSelf: () -> Unit,
|
onCloseSelf: () -> Unit,
|
||||||
onQuitApp: () -> Unit
|
onDisconnectServer: () -> Unit
|
||||||
) {
|
) {
|
||||||
val serverId = bufferKey.serverId
|
val serverId = bufferKey.serverId
|
||||||
val channelName = bufferKey.name
|
val channelName = bufferKey.name
|
||||||
@@ -59,15 +74,23 @@ fun ChannelWindow(
|
|||||||
val buffer by remember(bufferKey) { derivedStateOf { buffers[bufferKey] } }
|
val buffer by remember(bufferKey) { derivedStateOf { buffers[bufferKey] } }
|
||||||
val ownNicks by ConnectionManager.ownNicksFlow.collectAsState()
|
val ownNicks by ConnectionManager.ownNicksFlow.collectAsState()
|
||||||
val ownNick by remember(serverId) { derivedStateOf { ownNicks[serverId] } }
|
val ownNick by remember(serverId) { derivedStateOf { ownNicks[serverId] } }
|
||||||
val windowState = rememberWindowState()
|
// Restored from window_prefs.json (or cascaded if there's no usable saved position)
|
||||||
|
// and saved back on move/resize/close - see WindowPlacement.kt.
|
||||||
|
val windowState = rememberPersistentWindowState(prefsRepo, bufferKey.storageId(), DpSize(800.dp, 600.dp))
|
||||||
|
// Snap to the desktop grid after a move, and to whole lines after a resize
|
||||||
|
val lineGap = rememberLineGap()
|
||||||
|
WindowSnapping(windowState, lineGap)
|
||||||
|
|
||||||
// undecorated + transparent trade the native OS title bar for real per-pixel window
|
// undecorated + transparent trade the native OS title bar for real per-pixel window
|
||||||
// transparency (Windows only honors transparency on undecorated windows) - ChannelScreen
|
// transparency (Windows only honors transparency on undecorated windows) - ChannelScreen
|
||||||
// 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 when [visible] is false - the window and its composition stay
|
||||||
|
// alive, so its buffer keeps filling while hidden.
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = onCloseSelf,
|
onCloseRequest = onCloseSelf,
|
||||||
|
visible = visible,
|
||||||
title = windowTitle,
|
title = windowTitle,
|
||||||
icon = painterResource("icon.png"),
|
icon = painterResource("icon.png"),
|
||||||
state = windowState,
|
state = windowState,
|
||||||
@@ -75,16 +98,30 @@ fun ChannelWindow(
|
|||||||
transparent = true,
|
transparent = true,
|
||||||
resizable = true
|
resizable = true
|
||||||
) {
|
) {
|
||||||
|
TrayWindowBehavior(windowState, onHide)
|
||||||
|
CompositionLocalProvider(LocalLineGap provides lineGap) {
|
||||||
MaterialTheme(colorScheme = colorScheme) {
|
MaterialTheme(colorScheme = colorScheme) {
|
||||||
ChannelScreen(
|
ChannelScreen(
|
||||||
windowState = windowState,
|
onMinimize = onHide,
|
||||||
onCloseWindow = onCloseSelf,
|
onCloseWindow = onCloseSelf,
|
||||||
channelName = windowTitle,
|
channelName = windowTitle,
|
||||||
buffer = buffer,
|
buffer = buffer,
|
||||||
displaySettings = displaySettings,
|
displaySettings = displaySettings,
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
ownNick = ownNick,
|
ownNick = ownNick,
|
||||||
onSend = { text ->
|
onSend = onSend@{ text ->
|
||||||
|
// Everything except /reload, aliases and /part (which only closes
|
||||||
|
// the window) needs the server - while disconnected it gets this
|
||||||
|
// line in this window instead of going nowhere.
|
||||||
|
val needsServer = if (text.startsWith("/")) {
|
||||||
|
val command = text.substring(1).substringBefore(' ').uppercase()
|
||||||
|
command != "RELOAD" && command !in setOf("PART", "EXIT", "CLOSE") &&
|
||||||
|
!AliasRepository.hasAlias(command)
|
||||||
|
} else true
|
||||||
|
if (needsServer && !ConnectionManager.isConnected(serverId)) {
|
||||||
|
ConnectionManager.addSystemMessage(serverId, channelName, NOT_CONNECTED_TEXT)
|
||||||
|
return@onSend
|
||||||
|
}
|
||||||
if (text.startsWith("/")) {
|
if (text.startsWith("/")) {
|
||||||
val withoutSlash = text.substring(1)
|
val withoutSlash = text.substring(1)
|
||||||
val spaceIdx = withoutSlash.indexOf(' ')
|
val spaceIdx = withoutSlash.indexOf(' ')
|
||||||
@@ -109,21 +146,24 @@ fun ChannelWindow(
|
|||||||
"NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args")
|
"NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args")
|
||||||
"PART", "EXIT", "CLOSE" -> {
|
"PART", "EXIT", "CLOSE" -> {
|
||||||
val target = args.ifBlank { channelName }
|
val target = args.ifBlank { channelName }
|
||||||
ConnectionManager.connectionFor(serverId)?.partChannel(target, "leaving")
|
ConnectionManager.partChannel(serverId, target, "leaving")
|
||||||
if (args.isBlank() || target == channelName) onCloseSelf()
|
if (args.isBlank() || target == channelName) onCloseSelf()
|
||||||
}
|
}
|
||||||
"QUIT" -> {
|
"QUIT" -> {
|
||||||
if (rawArgs.isBlank()) {
|
if (rawArgs.isBlank()) {
|
||||||
ConnectionManager.disconnectAll()
|
// Disconnects this window's server only - every
|
||||||
onQuitApp()
|
// window stays open; see ConnectionManager.disconnect.
|
||||||
|
onDisconnectServer()
|
||||||
} else {
|
} else {
|
||||||
ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs)
|
ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"JOIN" -> {
|
"JOIN" -> {
|
||||||
ConnectionManager.sendRaw(serverId, "JOIN $args")
|
ConnectionManager.joinChannels(serverId, args)
|
||||||
onOpenBuffer(BufferKey(serverId, args))
|
onOpenBuffer(BufferKey(serverId, args))
|
||||||
}
|
}
|
||||||
|
// All whois output goes to wIRC main, not this window.
|
||||||
|
"WHOIS" -> ConnectionManager.whois(serverId, args)
|
||||||
"MSG" -> {
|
"MSG" -> {
|
||||||
val msgSpaceIdx = args.indexOf(' ')
|
val msgSpaceIdx = args.indexOf(' ')
|
||||||
if (msgSpaceIdx >= 0) {
|
if (msgSpaceIdx >= 0) {
|
||||||
@@ -139,10 +179,10 @@ fun ChannelWindow(
|
|||||||
ConnectionManager.sendChannelMessage(serverId, channelName, text)
|
ConnectionManager.sendChannelMessage(serverId, channelName, text)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onKick = { nick -> ConnectionManager.connectionFor(serverId)?.kick(channelName, nick) },
|
onKick = { nick -> withConnection(serverId, channelName) { it.kick(channelName, nick) } },
|
||||||
onBan = { nick -> ConnectionManager.connectionFor(serverId)?.ban(channelName, "$nick!*@*") },
|
onBan = { nick -> withConnection(serverId, channelName) { it.ban(channelName, "$nick!*@*") } },
|
||||||
onOp = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "+o", nick) },
|
onOp = { nick -> withConnection(serverId, channelName) { it.setMode(channelName, "+o", nick) } },
|
||||||
onDeop = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "-o", nick) },
|
onDeop = { nick -> withConnection(serverId, channelName) { it.setMode(channelName, "-o", nick) } },
|
||||||
onAlphaChange = { alpha ->
|
onAlphaChange = { alpha ->
|
||||||
scope.launch(Dispatchers.IO) { prefsRepo.setAlpha(bufferKey.storageId(), alpha) }
|
scope.launch(Dispatchers.IO) { prefsRepo.setAlpha(bufferKey.storageId(), alpha) }
|
||||||
},
|
},
|
||||||
@@ -152,6 +192,9 @@ fun ChannelWindow(
|
|||||||
onFontSizeChange = { size ->
|
onFontSizeChange = { size ->
|
||||||
scope.launch(Dispatchers.IO) { prefsRepo.setFontSize(bufferKey.storageId(), size) }
|
scope.launch(Dispatchers.IO) { prefsRepo.setFontSize(bufferKey.storageId(), size) }
|
||||||
},
|
},
|
||||||
|
onFontChange = { id ->
|
||||||
|
scope.launch(Dispatchers.IO) { prefsRepo.setFontId(bufferKey.storageId(), id) }
|
||||||
|
},
|
||||||
onShowUserListChange = { show ->
|
onShowUserListChange = { show ->
|
||||||
scope.launch(Dispatchers.IO) { prefsRepo.setShowUserList(bufferKey.storageId(), show) }
|
scope.launch(Dispatchers.IO) { prefsRepo.setShowUserList(bufferKey.storageId(), show) }
|
||||||
},
|
},
|
||||||
@@ -166,5 +209,6 @@ fun ChannelWindow(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -11,7 +13,9 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.isShiftPressed
|
import androidx.compose.ui.input.key.isShiftPressed
|
||||||
@@ -23,9 +27,12 @@ import androidx.compose.ui.unit.DpSize
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.DialogWindow
|
import androidx.compose.ui.window.DialogWindow
|
||||||
import androidx.compose.ui.window.rememberDialogState
|
import androidx.compose.ui.window.rememberDialogState
|
||||||
|
import data.MAIN_BUFFER_KEY
|
||||||
import data.ServerConfig
|
import data.ServerConfig
|
||||||
import data.ServerRepository
|
import data.ServerRepository
|
||||||
|
import data.WindowDisplaySettings
|
||||||
import irc.ConnectionManager
|
import irc.ConnectionManager
|
||||||
|
import irc.mircPalette
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -33,17 +40,69 @@ import kotlinx.coroutines.launch
|
|||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
repo: ServerRepository,
|
repo: ServerRepository,
|
||||||
colorScheme: ColorScheme,
|
colorScheme: ColorScheme,
|
||||||
|
displaySettings: WindowDisplaySettings,
|
||||||
|
autoConnectServerIds: Set<String>,
|
||||||
onOpenChannel: (serverId: String, channelName: String) -> Unit,
|
onOpenChannel: (serverId: String, channelName: String) -> Unit,
|
||||||
onExitApp: () -> Unit
|
onExitApp: () -> Unit,
|
||||||
|
onUnreadChange: (Int) -> Unit,
|
||||||
|
onFontChange: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
|
var showFontDialog by remember { mutableStateOf(false) }
|
||||||
var servers by remember { mutableStateOf(repo.loadAll()) }
|
var servers by remember { mutableStateOf(repo.loadAll()) }
|
||||||
var showAddDialog by remember { mutableStateOf(false) }
|
var showAddDialog by remember { mutableStateOf(false) }
|
||||||
var editingServer by remember { mutableStateOf<ServerConfig?>(null) }
|
var editingServer by remember { mutableStateOf<ServerConfig?>(null) }
|
||||||
var connectedIds by remember { mutableStateOf(setOf<String>()) }
|
// From ConnectionManager rather than local state, so a /quit typed in a channel window
|
||||||
|
// flips that server's card back to Disconnected with its Connect button.
|
||||||
|
val connectedIds by ConnectionManager.connectedIds.collectAsState()
|
||||||
var channelInputs by remember { mutableStateOf(mapOf<String, String>()) }
|
var channelInputs by remember { mutableStateOf(mapOf<String, String>()) }
|
||||||
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 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 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
|
||||||
|
var lastSeenMillis by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||||
|
val newest = mainMessages.lastOrNull()
|
||||||
|
LaunchedEffect(windowFocused, newest?.seq) {
|
||||||
|
if (windowFocused) {
|
||||||
|
lastSeenMillis = maxOf(lastSeenMillis, newest?.timestampMillis ?: 0L, System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val unread = if (windowFocused) 0 else mainMessages.count { it.timestampMillis > lastSeenMillis }
|
||||||
|
LaunchedEffect(unread) { onUnreadChange(unread) }
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -56,9 +115,14 @@ fun MainScreen(
|
|||||||
Text("+ Add Server")
|
Text("+ Add Server")
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
// Same end result as typing "/quit" alone in a channel window - disconnects
|
// Font for the server messages box (channel windows pick theirs in their own
|
||||||
// every server and terminates the process - just reachable without needing
|
// settings).
|
||||||
// a channel window open first.
|
TextButton(onClick = { showFontDialog = true }) {
|
||||||
|
Text("Font")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
// Disconnects every server and terminates the process. (A bare "/quit" in a
|
||||||
|
// channel window only disconnects that window's server.)
|
||||||
TextButton(onClick = onExitApp) {
|
TextButton(onClick = onExitApp) {
|
||||||
Text("Exit wIRC")
|
Text("Exit wIRC")
|
||||||
}
|
}
|
||||||
@@ -78,24 +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) },
|
||||||
ConnectionManager.connect(server)
|
|
||||||
connectedIds = connectedIds + server.id
|
|
||||||
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)
|
||||||
@@ -125,6 +172,54 @@ fun MainScreen(
|
|||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Server messages", style = MaterialTheme.typography.labelMedium)
|
||||||
|
if (unread > 0) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"\u25CF $unread new",
|
||||||
|
color = UNREAD_INDICATOR_COLOR,
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
// MOTD, numerics, errors, services/server notices and all /whois output - see
|
||||||
|
// ConnectionManager. Same rendering, auto-scroll and text selection as a channel
|
||||||
|
// window.
|
||||||
|
MessageScrollback(
|
||||||
|
messages = mainMessages,
|
||||||
|
displaySettings = displaySettings,
|
||||||
|
textColor = textColor,
|
||||||
|
isMention = { false },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(Color.Black.copy(alpha = displaySettings.backgroundAlpha))
|
||||||
|
.border(1.dp, Color.Gray.copy(alpha = 0.5f))
|
||||||
|
.padding(vertical = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showFontDialog) {
|
||||||
|
DialogWindow(
|
||||||
|
onCloseRequest = { showFontDialog = false },
|
||||||
|
title = "wIRC main font",
|
||||||
|
state = rememberDialogState(size = DpSize(300.dp, 360.dp)),
|
||||||
|
resizable = true
|
||||||
|
) {
|
||||||
|
MaterialTheme(colorScheme = colorScheme) {
|
||||||
|
Surface {
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(8.dp).verticalScroll(rememberScrollState())) {
|
||||||
|
Text("Server messages font:", color = Color.White, style = MaterialTheme.typography.bodySmall)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
FontPickerList(displaySettings.fontId, Color.White, onFontChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showAddDialog) {
|
if (showAddDialog) {
|
||||||
@@ -141,6 +236,8 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val UNREAD_INDICATOR_COLOR = Color(0xFFFFA500)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ServerCard(
|
private fun ServerCard(
|
||||||
server: ServerConfig,
|
server: ServerConfig,
|
||||||
@@ -183,6 +280,8 @@ private fun ServerCard(
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
|
Text("Disconnected", style = MaterialTheme.typography.bodySmall)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
Button(onClick = onConnect) { Text("Connect") }
|
Button(onClick = onConnect) { Text("Connect") }
|
||||||
} else {
|
} else {
|
||||||
Text("Connected", style = MaterialTheme.typography.bodySmall)
|
Text("Connected", style = MaterialTheme.typography.bodySmall)
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.isSpecified
|
||||||
|
import androidx.compose.ui.window.WindowPlacement
|
||||||
|
import androidx.compose.ui.window.WindowPosition
|
||||||
|
import androidx.compose.ui.window.WindowState
|
||||||
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
|
import data.WindowBounds
|
||||||
|
import data.WindowPrefsRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.awt.GraphicsEnvironment
|
||||||
|
import java.awt.Rectangle
|
||||||
|
import java.awt.Toolkit
|
||||||
|
|
||||||
|
// How long a window has to sit still after a move/resize before its bounds are written
|
||||||
|
// to window_prefs.json - dragging fires a position change per mouse event.
|
||||||
|
private const val BOUNDS_SAVE_DEBOUNCE_MS = 500L
|
||||||
|
|
||||||
|
// Cascade layout for windows with no usable saved position: the first one opens
|
||||||
|
// CASCADE_MARGIN_DP in from the top-left of the primary screen's usable area (taskbar
|
||||||
|
// excluded), each next one CASCADE_STEP_DP further down and right, wrapping back to the
|
||||||
|
// start before a window would run off the screen.
|
||||||
|
private const val CASCADE_MARGIN_DP = 40
|
||||||
|
private const val CASCADE_STEP_DP = 32
|
||||||
|
|
||||||
|
// A saved position only counts as "on a connected monitor" if at least this much of the
|
||||||
|
// window's top strip (where the drag handle / title bar is) lands on one screen, so a
|
||||||
|
// restored window can always be grabbed and moved.
|
||||||
|
private const val TITLE_STRIP_HEIGHT_DP = 32
|
||||||
|
private const val MIN_VISIBLE_TITLE_WIDTH_DP = 80
|
||||||
|
|
||||||
|
// Saved sizes smaller than this are treated as invalid and replaced by the default.
|
||||||
|
private const val MIN_WINDOW_WIDTH_DP = 200
|
||||||
|
private const val MIN_WINDOW_HEIGHT_DP = 120
|
||||||
|
|
||||||
|
private object WindowCascade {
|
||||||
|
private var next = 0
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun nextPosition(size: DpSize): WindowPosition {
|
||||||
|
val area = primaryUsableArea()
|
||||||
|
val roomX = area.width - CASCADE_MARGIN_DP - size.width.value
|
||||||
|
val roomY = area.height - CASCADE_MARGIN_DP - size.height.value
|
||||||
|
val stepsThatFit = (minOf(roomX, roomY) / CASCADE_STEP_DP).toInt().coerceAtLeast(0)
|
||||||
|
val step = next++ % (stepsThatFit + 1)
|
||||||
|
val offset = CASCADE_MARGIN_DP + step * CASCADE_STEP_DP
|
||||||
|
return WindowPosition.Absolute((area.x + offset).dp, (area.y + offset).dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun primaryUsableArea(): Rectangle = try {
|
||||||
|
val config = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration
|
||||||
|
val bounds = config.bounds
|
||||||
|
val insets = Toolkit.getDefaultToolkit().getScreenInsets(config)
|
||||||
|
Rectangle(
|
||||||
|
bounds.x + insets.left,
|
||||||
|
bounds.y + insets.top,
|
||||||
|
bounds.width - insets.left - insets.right,
|
||||||
|
bounds.height - insets.top - insets.bottom
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Rectangle(0, 0, 1280, 720)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun screenBounds(): List<Rectangle> = try {
|
||||||
|
GraphicsEnvironment.getLocalGraphicsEnvironment().screenDevices.map { it.defaultConfiguration.bounds }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isOnConnectedScreen(x: Float, y: Float, width: Float): Boolean {
|
||||||
|
val strip = Rectangle(x.toInt(), y.toInt(), width.toInt().coerceAtLeast(1), TITLE_STRIP_HEIGHT_DP)
|
||||||
|
val minVisibleWidth = minOf(MIN_VISIBLE_TITLE_WIDTH_DP, strip.width)
|
||||||
|
return screenBounds().any { screen ->
|
||||||
|
val visible = screen.intersection(strip)
|
||||||
|
!visible.isEmpty && visible.width >= minVisibleWidth && visible.height >= TITLE_STRIP_HEIGHT_DP / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveInitialPlacement(saved: WindowBounds?, defaultSize: DpSize): Pair<WindowPosition, DpSize> {
|
||||||
|
val savedWidth = saved?.width
|
||||||
|
val savedHeight = saved?.height
|
||||||
|
val size = if (savedWidth != null && savedHeight != null &&
|
||||||
|
savedWidth >= MIN_WINDOW_WIDTH_DP && savedHeight >= MIN_WINDOW_HEIGHT_DP
|
||||||
|
) {
|
||||||
|
// Never restore a window bigger than the largest connected screen (e.g. it was
|
||||||
|
// saved on a monitor that has since been unplugged).
|
||||||
|
val screens = screenBounds()
|
||||||
|
val maxWidth = screens.maxOfOrNull { it.width }?.toFloat() ?: savedWidth
|
||||||
|
val maxHeight = screens.maxOfOrNull { it.height }?.toFloat() ?: savedHeight
|
||||||
|
DpSize(minOf(savedWidth, maxWidth).dp, minOf(savedHeight, maxHeight).dp)
|
||||||
|
} else {
|
||||||
|
defaultSize
|
||||||
|
}
|
||||||
|
|
||||||
|
val savedX = saved?.x
|
||||||
|
val savedY = saved?.y
|
||||||
|
val position = if (savedX != null && savedY != null && isOnConnectedScreen(savedX, savedY, size.width.value)) {
|
||||||
|
WindowPosition.Absolute(savedX.dp, savedY.dp)
|
||||||
|
} else {
|
||||||
|
WindowCascade.nextPosition(size)
|
||||||
|
}
|
||||||
|
return position to size
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBoundsIfFloating(state: WindowState, prefsRepo: WindowPrefsRepository, storageId: String) {
|
||||||
|
// Minimized windows report a parking position far off-screen on Windows, and a
|
||||||
|
// maximized/fullscreen window's bounds are the screen's - neither is a position the
|
||||||
|
// user chose, so only plain floating bounds are remembered.
|
||||||
|
if (state.isMinimized || state.placement != WindowPlacement.Floating) return
|
||||||
|
val position = state.position
|
||||||
|
val size = state.size
|
||||||
|
if (position !is WindowPosition.Absolute || !size.isSpecified) return
|
||||||
|
prefsRepo.setWindowBounds(
|
||||||
|
storageId,
|
||||||
|
WindowBounds(position.x.value, position.y.value, size.width.value, size.height.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A WindowState that starts at the window's saved position and size from window_prefs.json
|
||||||
|
* (if that position is still on a connected monitor), otherwise at the next cascade slot,
|
||||||
|
* and writes its bounds back whenever the window is moved or resized and when it closes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberPersistentWindowState(
|
||||||
|
prefsRepo: WindowPrefsRepository,
|
||||||
|
storageId: String,
|
||||||
|
defaultSize: DpSize
|
||||||
|
): WindowState {
|
||||||
|
val (initialPosition, initialSize) = remember(storageId) {
|
||||||
|
resolveInitialPlacement(prefsRepo.boundsFor(storageId), defaultSize)
|
||||||
|
}
|
||||||
|
val state = rememberWindowState(position = initialPosition, size = initialSize)
|
||||||
|
|
||||||
|
LaunchedEffect(state, storageId) {
|
||||||
|
snapshotFlow { listOf(state.position, state.size, state.placement, state.isMinimized) }
|
||||||
|
.collectLatest {
|
||||||
|
delay(BOUNDS_SAVE_DEBOUNCE_MS)
|
||||||
|
withContext(Dispatchers.IO) { saveBoundsIfFloating(state, prefsRepo, storageId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Catches a move/resize made less than BOUNDS_SAVE_DEBOUNCE_MS before the window closed.
|
||||||
|
DisposableEffect(state, storageId) {
|
||||||
|
onDispose { saveBoundsIfFloating(state, prefsRepo, storageId) }
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.isSpecified
|
||||||
|
import androidx.compose.ui.window.WindowPlacement
|
||||||
|
import androidx.compose.ui.window.WindowPosition
|
||||||
|
import androidx.compose.ui.window.WindowState
|
||||||
|
import java.awt.AWTEvent
|
||||||
|
import java.awt.GraphicsEnvironment
|
||||||
|
import java.awt.Rectangle
|
||||||
|
import java.awt.Toolkit
|
||||||
|
import java.awt.event.AWTEventListener
|
||||||
|
import java.awt.event.MouseEvent
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Space left over under the last whole line of a window's scrollback, reported by
|
||||||
|
* MessageScrollback (which already sizes the scrollback itself to whole lines). When a
|
||||||
|
* resize ends, the window is trimmed by this much so the window itself fits whole lines too.
|
||||||
|
*/
|
||||||
|
class LineGap {
|
||||||
|
@Volatile var gap: Dp = 0.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalLineGap = staticCompositionLocalOf<LineGap?> { null }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberLineGap(): LineGap = remember { LineGap() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After the mouse is let go at the end of a drag or resize:
|
||||||
|
* - a move snaps the window's top-left corner to the desktop icon grid
|
||||||
|
* - a resize trims the window to whole lines of scrollback text (keeping whichever edge
|
||||||
|
* didn't move in place)
|
||||||
|
* Channel windows are undecorated, so their moves and resizes happen inside the app (the
|
||||||
|
* title-bar drag area and Compose's own edge resizer) and the mouse release reaches wIRC.
|
||||||
|
* wIRC main has a normal Windows frame, whose drags Windows handles itself without telling
|
||||||
|
* the app when the button comes up, so for it ([osFrame] = true) the snap happens once the
|
||||||
|
* window has sat still for a moment instead.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WindowSnapping(state: WindowState, lineGap: LineGap, osFrame: Boolean = false) {
|
||||||
|
if (osFrame) {
|
||||||
|
LaunchedEffect(state, lineGap) {
|
||||||
|
var settledPos: WindowPosition = state.position
|
||||||
|
var settledSize: DpSize = state.size
|
||||||
|
snapshotFlow { state.position to state.size }.collectLatest {
|
||||||
|
delay(OS_FRAME_SETTLE_MS)
|
||||||
|
afterMoveOrResize(state, lineGap, settledPos, settledSize)
|
||||||
|
settledPos = state.position
|
||||||
|
settledSize = state.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DisposableEffect(state, lineGap) {
|
||||||
|
var startPos: WindowPosition? = null
|
||||||
|
var startSize: DpSize? = null
|
||||||
|
val listener = AWTEventListener { event ->
|
||||||
|
val e = event as? MouseEvent ?: return@AWTEventListener
|
||||||
|
if (e.button != MouseEvent.BUTTON1) return@AWTEventListener
|
||||||
|
when (e.id) {
|
||||||
|
MouseEvent.MOUSE_PRESSED -> {
|
||||||
|
startPos = state.position
|
||||||
|
startSize = state.size
|
||||||
|
}
|
||||||
|
MouseEvent.MOUSE_RELEASED -> {
|
||||||
|
val p0 = startPos
|
||||||
|
val s0 = startSize
|
||||||
|
startPos = null
|
||||||
|
startSize = null
|
||||||
|
if (p0 != null && s0 != null) afterMoveOrResize(state, lineGap, p0, s0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toolkit.getDefaultToolkit().addAWTEventListener(listener, AWTEvent.MOUSE_EVENT_MASK)
|
||||||
|
onDispose { Toolkit.getDefaultToolkit().removeAWTEventListener(listener) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// How long wIRC main has to sit still after a move/resize before it snaps
|
||||||
|
private const val OS_FRAME_SETTLE_MS = 400L
|
||||||
|
|
||||||
|
private fun afterMoveOrResize(state: WindowState, lineGap: LineGap, p0: WindowPosition, s0: DpSize) {
|
||||||
|
if (state.isMinimized || state.placement != WindowPlacement.Floating) return
|
||||||
|
val pos = state.position as? WindowPosition.Absolute ?: return
|
||||||
|
val size = state.size
|
||||||
|
if (!size.isSpecified || !s0.isSpecified) return
|
||||||
|
val start = p0 as? WindowPosition.Absolute
|
||||||
|
|
||||||
|
val resized = (size.width - s0.width).value.let { it > 0.5f || it < -0.5f } ||
|
||||||
|
(size.height - s0.height).value.let { it > 0.5f || it < -0.5f }
|
||||||
|
|
||||||
|
if (resized) {
|
||||||
|
val gap = lineGap.gap
|
||||||
|
if (gap > 0.5.dp && size.height - gap >= 120.dp) {
|
||||||
|
// Dragged the top edge (top moved, bottom stayed): keep the bottom where it is
|
||||||
|
val fromTop = start != null && (pos.y - start.y).value.let { it > 0.5f || it < -0.5f } &&
|
||||||
|
((pos.y + size.height) - (start.y + s0.height)).value.let { it < 0.5f && it > -0.5f }
|
||||||
|
state.size = DpSize(size.width, size.height - gap)
|
||||||
|
if (fromTop) state.position = WindowPosition.Absolute(pos.x, pos.y + gap)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start == null || (start.x == pos.x && start.y == pos.y)) return
|
||||||
|
val (sx, sy) = DesktopGrid.spacing() ?: return
|
||||||
|
val area = workAreaAt(pos.x.value, pos.y.value)
|
||||||
|
val x = area.x + ((pos.x.value - area.x) / sx).roundToInt() * sx
|
||||||
|
val y = area.y + ((pos.y.value - area.y) / sy).roundToInt() * sy
|
||||||
|
if (x != pos.x.value || y != pos.y.value) state.position = WindowPosition.Absolute(x.dp, y.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The usable area (taskbar excluded) of the screen the window's corner is on, in the same
|
||||||
|
// logical units as WindowState positions.
|
||||||
|
private fun workAreaAt(x: Float, y: Float): Rectangle = try {
|
||||||
|
val ge = GraphicsEnvironment.getLocalGraphicsEnvironment()
|
||||||
|
val config = ge.screenDevices.map { it.defaultConfiguration }
|
||||||
|
.firstOrNull { it.bounds.contains(x.toInt(), y.toInt()) } ?: ge.defaultScreenDevice.defaultConfiguration
|
||||||
|
val b = config.bounds
|
||||||
|
val i = Toolkit.getDefaultToolkit().getScreenInsets(config)
|
||||||
|
Rectangle(b.x + i.left, b.y + i.top, b.width - i.left - i.right, b.height - i.top - i.bottom)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Rectangle(0, 0, 1280, 720)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The desktop icon grid's spacing, read from the desktop's own icon view (so it follows the
|
||||||
|
* icon size and spacing Windows is really using), falling back to the system icon spacing.
|
||||||
|
* Java can't call Windows directly without extra libraries, so a hidden PowerShell asks once
|
||||||
|
* at startup, in the background; snapping just skips until the answer is in.
|
||||||
|
*/
|
||||||
|
object DesktopGrid {
|
||||||
|
@Volatile private var cached: Pair<Float, Float>? = null
|
||||||
|
@Volatile private var started = false
|
||||||
|
|
||||||
|
fun spacing(): Pair<Float, Float>? {
|
||||||
|
if (!started) refreshAsync()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun refreshAsync() {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
val script = """
|
||||||
|
Add-Type -TypeDefinition @'
|
||||||
|
using System; using System.Runtime.InteropServices;
|
||||||
|
public static class WircGrid {
|
||||||
|
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern IntPtr FindWindow(string c, string n);
|
||||||
|
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern IntPtr FindWindowEx(IntPtr p, IntPtr a, string c, string n);
|
||||||
|
[DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr h, int m, IntPtr w, IntPtr l);
|
||||||
|
[DllImport("user32.dll")] static extern bool SystemParametersInfo(uint a, uint p, out int v, uint i);
|
||||||
|
public static string Get() {
|
||||||
|
IntPtr dv = FindWindowEx(FindWindow("Progman", null), IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||||
|
IntPtr w = IntPtr.Zero;
|
||||||
|
while (dv == IntPtr.Zero) { w = FindWindowEx(IntPtr.Zero, w, "WorkerW", null); if (w == IntPtr.Zero) break; dv = FindWindowEx(w, IntPtr.Zero, "SHELLDLL_DefView", null); }
|
||||||
|
IntPtr lv = dv == IntPtr.Zero ? IntPtr.Zero : FindWindowEx(dv, IntPtr.Zero, "SysListView32", null);
|
||||||
|
if (lv != IntPtr.Zero) { long r = SendMessage(lv, 0x1033, IntPtr.Zero, IntPtr.Zero).ToInt64(); long cx = r & 0xFFFF, cy = (r >> 16) & 0xFFFF; if (cx > 0 && cy > 0) return cx + "," + cy; }
|
||||||
|
int sx, sy; SystemParametersInfo(0x000D, 0, out sx, 0); SystemParametersInfo(0x0018, 0, out sy, 0);
|
||||||
|
return sx + "," + sy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
[WircGrid]::Get()
|
||||||
|
""".trimIndent()
|
||||||
|
val encoded = Base64.getEncoder().encodeToString(script.toByteArray(Charsets.UTF_16LE))
|
||||||
|
val proc = ProcessBuilder("powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded)
|
||||||
|
.redirectErrorStream(true).start()
|
||||||
|
val out = proc.inputStream.bufferedReader().readText()
|
||||||
|
proc.waitFor(30, TimeUnit.SECONDS)
|
||||||
|
val m = Regex("""(\d+),(\d+)""").findAll(out).lastOrNull() ?: return@Thread
|
||||||
|
val px = m.groupValues[1].toFloat()
|
||||||
|
val py = m.groupValues[2].toFloat()
|
||||||
|
// Physical pixels at the primary screen's scaling -> the logical units windows use
|
||||||
|
val scale = try {
|
||||||
|
GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration.defaultTransform.scaleX.toFloat()
|
||||||
|
} catch (e: Exception) { 1f }.coerceAtLeast(0.5f)
|
||||||
|
if (px > 8 && py > 8) cached = (px / scale) to (py / scale)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// No grid info: windows just don't snap to it
|
||||||
|
}
|
||||||
|
}.apply { isDaemon = true; name = "wIRC desktop grid" }.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole lines that fit in [height] at [lineHeight], with a little slack for rounding. */
|
||||||
|
internal fun wholeLines(height: Dp, lineHeight: Dp): Int =
|
||||||
|
floor((height.value + 0.5f) / lineHeight.value).toInt().coerceAtLeast(1)
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2011 The Quicksand Project Authors (https://github.com/andrew-paglinawan/QuicksandFamily), with Reserved Font Name “Quicksand”.
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2023 The Varela Round Project Authors (https://github.com/alefalefalef/Varela-Round-Hebrew/), with Reserved Font Names 'Varela' and ‘Varela Round’.
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user