Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d58b5772df | ||
|
|
fff75700ac | ||
|
|
ed5d9a9c78 | ||
|
|
03964ca313 | ||
|
|
78bd235a48 | ||
|
|
24f54574ec | ||
|
|
4ac7b75962 | ||
|
|
5bcd1a7bcd | ||
|
|
026e349db8 | ||
|
|
efad80e141 | ||
|
|
3538274805 | ||
|
|
d9a0297cc7 |
+5
-3
@@ -1,4 +1,4 @@
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
|
||||
plugins {
|
||||
kotlin("jvm") version "1.9.24"
|
||||
@@ -6,7 +6,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "com.tpuahsiew.wirc"
|
||||
version = "0.1.7"
|
||||
version = "0.1.17"
|
||||
|
||||
// No project-level repositories{} block here: settings.gradle.kts sets
|
||||
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
|
||||
@@ -22,6 +22,8 @@ dependencies {
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1")
|
||||
implementation("org.json:json:20240303")
|
||||
// Native Windows calls that keep wIRC's windows off the taskbar (see AppTray.kt)
|
||||
implementation("net.java.dev.jna:jna-platform:5.14.0")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -38,7 +40,7 @@ compose.desktop {
|
||||
appResourcesRootDir.set(project.file("installerResources"))
|
||||
targetFormats(TargetFormat.Msi, TargetFormat.Exe)
|
||||
packageName = "wIRC"
|
||||
packageVersion = "0.1.7"
|
||||
packageVersion = "0.1.17"
|
||||
windows {
|
||||
menuGroup = "wIRC"
|
||||
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
|
||||
|
||||
+68
-19
@@ -1,6 +1,7 @@
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -18,11 +19,14 @@ import data.AliasRepository
|
||||
import data.BufferKey
|
||||
import data.MAIN_BUFFER_KEY
|
||||
import data.ServerRepository
|
||||
import data.SessionState
|
||||
import data.SessionStateRepository
|
||||
import data.WindowPrefsRepository
|
||||
import data.storageId
|
||||
import irc.ConnectionManager
|
||||
import ui.ChannelWindow
|
||||
import ui.MainScreen
|
||||
import ui.TrayWindowBehavior
|
||||
import ui.rememberPersistentWindowState
|
||||
import java.io.File
|
||||
|
||||
@@ -51,6 +55,8 @@ fun main() = application {
|
||||
// Loaded once here (not per-window) so the alias map is truly global - every
|
||||
// ChannelWindow's command router reads from this same AliasRepository singleton.
|
||||
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 prefsRepo = remember { WindowPrefsRepository(appRoot) }
|
||||
@@ -58,16 +64,34 @@ fun main() = application {
|
||||
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
|
||||
var mainUnread by remember { mutableStateOf(0) }
|
||||
|
||||
// ConnectionManager routes WHOIS replies to the window that asked only while that
|
||||
// window is still open, so it needs to know which ones are.
|
||||
LaunchedEffect(openBuffers) {
|
||||
ConnectionManager.openWindows = openBuffers
|
||||
// wIRC lives in the tray: no window is on the taskbar, and minimizing a window hides just
|
||||
// that window (see TrayWindowBehavior). Hidden windows stay composed and connected, so
|
||||
// nothing is lost while they're away; showing them again puts each back where it was.
|
||||
// wIRC main starts hidden if it was hidden when wIRC last quit.
|
||||
val sessionRepo = remember { SessionStateRepository(appRoot) }
|
||||
val startSession = remember { sessionRepo.load() }
|
||||
var mainHidden by remember { mutableStateOf(startSession.mainHidden) }
|
||||
var hiddenBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
|
||||
val anyHidden = mainHidden || openBuffers.any { it in hiddenBuffers }
|
||||
val showAll: () -> Unit = { mainHidden = false; hiddenBuffers = emptySet() }
|
||||
// Windows opened after this (e.g. a new PM) still show up
|
||||
val hideAll: () -> Unit = { mainHidden = true; hiddenBuffers = openBuffers }
|
||||
|
||||
val saveSession: () -> Unit = {
|
||||
sessionRepo.saveOnce(SessionState(mainHidden, ConnectionManager.connectedIds.value))
|
||||
}
|
||||
// Also saves when Windows ends the process (sign-out, shutdown) instead of the user quitting
|
||||
DisposableEffect(Unit) {
|
||||
val hook = Thread { saveSession() }
|
||||
Runtime.getRuntime().addShutdownHook(hook)
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
// Full shutdown: same steps /quit (bare, no args) already runs from a channel window -
|
||||
// disconnect every server, then end the process. Shared here so the "Exit wIRC" button
|
||||
// and every channel window's /quit reuse the exact same logic instead of duplicating it.
|
||||
// Full shutdown for the "Exit wIRC" button: disconnect every server (which also writes
|
||||
// all scrollback to disk), then end the process. A bare /quit in a channel window only
|
||||
// disconnects that window's server - see ConnectionManager.disconnect.
|
||||
val shutdownApp: () -> Unit = {
|
||||
saveSession()
|
||||
ConnectionManager.disconnectAll()
|
||||
exitApplication()
|
||||
}
|
||||
@@ -91,33 +115,53 @@ fun main() = application {
|
||||
// ("wirc-main__main"), same as every channel and PM window - see WindowPlacement.kt.
|
||||
val mainWindowState = rememberPersistentWindowState(prefsRepo, MAIN_BUFFER_KEY.storageId(), DpSize(800.dp, 700.dp))
|
||||
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)
|
||||
|
||||
// There's no persistent tray/notification surface on this build, so closing the main
|
||||
// server-list window quits the whole app (and every channel window with it) - the
|
||||
// same "closing the primary window ends the session" convention most desktop chat
|
||||
// apps without a tray icon follow.
|
||||
val mainTitle = if (mainUnread > 0) "wIRC - Main ($mainUnread new)" else "wIRC - Main"
|
||||
|
||||
ui.AppTray(
|
||||
// The unread count shows on the tray icon's tooltip too, so new server messages
|
||||
// are visible even when the windows are hidden.
|
||||
tooltip = if (mainUnread > 0) "wIRC ($mainUnread new)" else "wIRC",
|
||||
onToggle = { if (anyHidden) showAll() else hideAll() },
|
||||
onShow = showAll,
|
||||
onShowMain = { mainHidden = false },
|
||||
onHide = hideAll,
|
||||
onExit = shutdownApp
|
||||
)
|
||||
|
||||
// Closing the main server-list window quits the whole app (and every channel window
|
||||
// with it) - the tray's Exit does the same, and also disconnects first.
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
// The unread count shows in the taskbar too, so new server messages are visible
|
||||
// even when the main window is behind other windows.
|
||||
title = if (mainUnread > 0) "wIRC - Main ($mainUnread new)" else "wIRC - Main",
|
||||
onCloseRequest = { saveSession(); exitApplication() },
|
||||
visible = !mainHidden,
|
||||
title = mainTitle,
|
||||
icon = painterResource("icon.png"),
|
||||
state = mainWindowState
|
||||
) {
|
||||
TrayWindowBehavior(mainWindowState) { mainHidden = true }
|
||||
androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) {
|
||||
MaterialTheme(colorScheme = neutralDarkScheme) {
|
||||
Surface {
|
||||
MainScreen(
|
||||
repo = repo,
|
||||
colorScheme = neutralDarkScheme,
|
||||
displaySettings = mainDisplaySettings,
|
||||
// Started hidden: reconnect what was connected at quit, so the channel
|
||||
// windows open without wIRC main having to be shown to click Connect.
|
||||
autoConnectServerIds = if (startSession.mainHidden) startSession.connectedServerIds else emptySet(),
|
||||
onOpenChannel = { serverId, channelName ->
|
||||
openBuffers = openBuffers + BufferKey(serverId, channelName)
|
||||
},
|
||||
onExitApp = shutdownApp,
|
||||
onUnreadChange = { mainUnread = it }
|
||||
onUnreadChange = { mainUnread = it },
|
||||
onFontChange = { id -> prefsRepo.setFontId(MAIN_BUFFER_KEY.storageId(), id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openBuffers.forEach { bufferKey ->
|
||||
@@ -126,10 +170,15 @@ fun main() = application {
|
||||
bufferKey = bufferKey,
|
||||
prefsRepo = prefsRepo,
|
||||
colorScheme = neutralDarkScheme,
|
||||
visible = bufferKey !in hiddenBuffers,
|
||||
onHide = { hiddenBuffers = hiddenBuffers + bufferKey },
|
||||
onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey },
|
||||
onCloseSelf = { openBuffers = openBuffers - bufferKey },
|
||||
onQuitApp = {
|
||||
exitApplication()
|
||||
// Also forgets it was hidden, so reopening it later shows it
|
||||
onCloseSelf = { openBuffers = openBuffers - bufferKey; hiddenBuffers = hiddenBuffers - bufferKey },
|
||||
// openBuffers is read when /quit runs, so every window open at that moment
|
||||
// gets its "Disconnected" line, even one that hasn't had a line yet.
|
||||
onDisconnectServer = {
|
||||
ConnectionManager.disconnect(bufferKey.serverId, openBuffers)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ object AliasRepository {
|
||||
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
|
||||
* action was run - callers should treat that as "handled, don't fall through to IRC
|
||||
|
||||
@@ -48,7 +48,7 @@ data class IrcMessage(
|
||||
data class BufferKey(val serverId: String, val name: String)
|
||||
|
||||
/**
|
||||
* wIRC main's own buffer: server messages (MOTD, numerics, errors, whois fallback) and
|
||||
* 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,18 @@ import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
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(
|
||||
val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque
|
||||
val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white
|
||||
val fontSize: Float = 14f,
|
||||
val fontId: String = "monospace", // see ui/AppFonts.kt; unknown/missing ids fall back to monospace
|
||||
val showUserList: Boolean = true,
|
||||
val mentionHighlightColorArgb: Int = 0x40FFA500.toInt(), // translucent orange
|
||||
val nickColorsEnabled: Boolean = true,
|
||||
@@ -62,7 +70,9 @@ class WindowPrefsRepository(rootDir: File) {
|
||||
|
||||
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) }
|
||||
|
||||
@@ -106,7 +116,9 @@ class WindowPrefsRepository(rootDir: File) {
|
||||
stored[key] = WindowDisplaySettings(
|
||||
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
|
||||
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),
|
||||
mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(),
|
||||
nickColorsEnabled = o.optBoolean("nickColorsEnabled", true),
|
||||
@@ -138,6 +150,7 @@ class WindowPrefsRepository(rootDir: File) {
|
||||
put("backgroundAlpha", settings.backgroundAlpha)
|
||||
put("textColorMircIndex", settings.textColorMircIndex)
|
||||
put("fontSize", settings.fontSize)
|
||||
put("fontId", settings.fontId)
|
||||
put("showUserList", settings.showUserList)
|
||||
put("mentionHighlightColorArgb", settings.mentionHighlightColorArgb)
|
||||
put("nickColorsEnabled", settings.nickColorsEnabled)
|
||||
|
||||
@@ -33,12 +33,22 @@ 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; 401/402 are the "no such nick/server" errors a WHOIS can get.
|
||||
// 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", "401", "402", "671"
|
||||
"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.
|
||||
@@ -63,13 +73,24 @@ object ConnectionManager {
|
||||
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
|
||||
|
||||
// Buffers that currently have a window open, kept up to date by Main.kt. Read when a
|
||||
// WHOIS reply arrives, to decide whether the window that asked is still there to show it.
|
||||
@Volatile
|
||||
var openWindows: Set<BufferKey> = emptySet()
|
||||
// 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 -> (lowercased target nick -> the buffer whose window typed the /whois).
|
||||
private val pendingWhois = ConcurrentHashMap<String, ConcurrentHashMap<String, BufferKey>>()
|
||||
// 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) {
|
||||
historyRepo = MessageHistoryRepository(rootDir)
|
||||
@@ -132,33 +153,100 @@ object ConnectionManager {
|
||||
existing.disconnect()
|
||||
}
|
||||
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,
|
||||
scope = scope,
|
||||
onLine = { line -> enqueue { handleLine(config.id, line) } },
|
||||
initialChannels = rejoin,
|
||||
// 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 ->
|
||||
_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") }
|
||||
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
|
||||
_connectedIds.update { it + config.id }
|
||||
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() {
|
||||
connections.values.forEach { it.disconnect() }
|
||||
connections.clear()
|
||||
_connectedIds.value = emptySet()
|
||||
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]
|
||||
|
||||
// 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
|
||||
// that sent it.
|
||||
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 msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
|
||||
enqueue { addOutgoing(serverId, target, msg, "-> *$target* $text") }
|
||||
@@ -168,7 +256,8 @@ object ConnectionManager {
|
||||
// CTCP ACTION wrapper, while the buffer should just hold the plain action text
|
||||
// (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix).
|
||||
fun sendChannelAction(serverId: String, target: String, action: String) {
|
||||
connectionFor(serverId)?.sendMessage(target, "\u0001ACTION $action\u0001")
|
||||
val conn = connectionFor(serverId) ?: return
|
||||
conn.sendMessage(target, "\u0001ACTION $action\u0001")
|
||||
val myNick = ownNicks[serverId] ?: return
|
||||
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
|
||||
enqueue { addOutgoing(serverId, target, msg, "-> *$target* * $myNick $action") }
|
||||
@@ -194,21 +283,70 @@ object ConnectionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends WHOIS and remembers which window asked, so the reply lines are shown in that
|
||||
* window - or in wIRC main if it has been closed by the time they arrive. [args] is
|
||||
* whatever followed "/whois"; its last word is the nick being looked up (the
|
||||
* "/whois server nick" form puts the nick last too).
|
||||
* 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, origin: BufferKey) {
|
||||
val nick = args.trim().split(Regex("\\s+")).lastOrNull()?.takeIf { it.isNotEmpty() }
|
||||
if (nick == null) {
|
||||
addSystemMessage(serverId, origin.name, "Usage: /whois <nick>")
|
||||
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
|
||||
}
|
||||
pendingWhois.getOrPut(serverId) { ConcurrentHashMap() }[nick.lowercase()] = origin
|
||||
// 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
|
||||
// wire - just appended to the buffer the same way any other SYSTEM line is.
|
||||
fun addSystemMessage(serverId: String, target: String, text: String) {
|
||||
@@ -282,17 +420,27 @@ object ConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
// A WHOIS reply line goes to the window that typed the /whois if it's still open,
|
||||
// otherwise (closed since, or a WHOIS nobody asked for here) to wIRC main.
|
||||
// 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 = pendingWhois[serverId]
|
||||
val origin = pending?.get(target.lowercase())
|
||||
if (line.command == "318") pending?.remove(target.lowercase())
|
||||
|
||||
val text = "[whois] " + formatWhoisLine(line)
|
||||
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.SYSTEM, null, text)
|
||||
if (origin != null && origin in openWindows) addMessage(origin, msg) else addMessage(MAIN_BUFFER_KEY, msg)
|
||||
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 {
|
||||
@@ -372,8 +520,13 @@ object ConnectionManager {
|
||||
} 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.
|
||||
addMainLine(MessageKind.NOTICE, nick, "-${nick ?: "server"}- $text")
|
||||
// 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.
|
||||
@@ -495,7 +648,7 @@ object ConnectionManager {
|
||||
"PONG", "CAP" -> Unit
|
||||
else -> {
|
||||
when {
|
||||
line.command in WHOIS_NUMERICS -> handleWhoisLine(serverId, line)
|
||||
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.
|
||||
|
||||
@@ -50,6 +50,9 @@ private fun translateOnConnectCommand(cmd: String): String {
|
||||
class IrcConnection(
|
||||
val config: ServerConfig,
|
||||
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 onStatus: (String) -> Unit
|
||||
) {
|
||||
@@ -60,13 +63,17 @@ class IrcConnection(
|
||||
@Volatile private var socket: Socket? = null
|
||||
@Volatile private var writer: PrintWriter? = 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
|
||||
|
||||
// Every channel we're currently in, whether from config.autoJoinChannels or joined
|
||||
// manually mid-session - so a reconnect rejoins everything, not just the original
|
||||
// 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
|
||||
// (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 = 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)
|
||||
writer = out
|
||||
|
||||
@@ -161,6 +171,9 @@ class IrcConnection(
|
||||
handleRawLine(line)
|
||||
}
|
||||
} 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}")
|
||||
ircDebug(config.id, "connection dropped: ${e.message} - tracked channels at drop time: $joinedChannels")
|
||||
} finally {
|
||||
@@ -277,6 +290,9 @@ class IrcConnection(
|
||||
try { writer?.println("QUIT :leaving") } catch (_: Exception) {}
|
||||
readJob?.cancel()
|
||||
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))
|
||||
}
|
||||
}
|
||||
+595
-115
@@ -1,12 +1,21 @@
|
||||
@file:OptIn(androidx.compose.ui.text.ExperimentalTextApi::class)
|
||||
|
||||
package ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ContextMenuArea
|
||||
import androidx.compose.foundation.ContextMenuItem
|
||||
import androidx.compose.foundation.DarkDefaultContextMenuRepresentation
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.LocalContextMenuRepresentation
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
@@ -18,8 +27,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -31,38 +40,62 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.PointerIcon
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.isPrimaryPressed
|
||||
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||
import androidx.compose.ui.input.pointer.isShiftPressed
|
||||
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.DialogWindow
|
||||
import androidx.compose.ui.window.WindowScope
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import androidx.compose.ui.window.rememberDialogState
|
||||
import data.ChannelBuffer
|
||||
import data.ChannelUser
|
||||
import data.IrcMessage
|
||||
import data.MAX_FONT_SIZE
|
||||
import data.MIN_FONT_SIZE
|
||||
import data.MessageKind
|
||||
import data.WindowDisplaySettings
|
||||
import irc.extractImageUrls
|
||||
import irc.mircPalette
|
||||
import irc.parseMircToAnnotatedString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -73,6 +106,7 @@ import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Title-bar button fade (nicklist / settings / minimize / close). The buttons sit at
|
||||
// TITLE_BAR_BUTTON_REST_ALPHA until hovered, then animate to TITLE_BAR_BUTTON_HOVER_ALPHA
|
||||
@@ -129,10 +163,29 @@ private fun openUrlInBrowser(url: String) {
|
||||
}
|
||||
}
|
||||
|
||||
// Writes to the system clipboard off the UI thread with a timeout - the same approach,
|
||||
// for the same reason, as ChannelScreen's pasteFromClipboardSafely: the AWT clipboard is a
|
||||
// blocking native call that can hang on Windows, and on the UI thread that hangs the whole
|
||||
// app. Worst case here is a silently failed copy and one parked IO thread.
|
||||
private fun copyToClipboardSafely(scope: CoroutineScope, text: String) {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
withTimeoutOrNull(2000) {
|
||||
java.awt.Toolkit.getDefaultToolkit().systemClipboard
|
||||
.setContents(java.awt.datatransfer.StringSelection(text), null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Clipboard unavailable or held by another app - nothing to do.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun WindowScope.ChannelScreen(
|
||||
windowState: WindowState,
|
||||
onMinimize: () -> Unit,
|
||||
onCloseWindow: () -> Unit,
|
||||
channelName: String,
|
||||
buffer: ChannelBuffer?,
|
||||
@@ -147,6 +200,7 @@ fun WindowScope.ChannelScreen(
|
||||
onAlphaChange: (Float) -> Unit,
|
||||
onTextColorChange: (Int) -> Unit,
|
||||
onFontSizeChange: (Float) -> Unit,
|
||||
onFontChange: (String) -> Unit,
|
||||
onShowUserListChange: (Boolean) -> Unit,
|
||||
onNickColorsToggle: (Boolean) -> Unit,
|
||||
onMentionHighlightColorChange: (Int) -> Unit,
|
||||
@@ -162,6 +216,7 @@ fun WindowScope.ChannelScreen(
|
||||
var draftBeforeHistory by remember { mutableStateOf("") }
|
||||
val inputFocusRequester = remember { FocusRequester() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val scrollbackSelection = rememberScrollbackSelectionState()
|
||||
|
||||
// Reading java.awt.Toolkit's system clipboard is a blocking native (JNI/AWT) call, and
|
||||
// on Windows it can genuinely hang - a well-documented class of bug where the OS-level
|
||||
@@ -321,7 +376,7 @@ fun WindowScope.ChannelScreen(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(channelName, color = textColor, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall)
|
||||
Text(channelName, color = textColor, fontFamily = AppFonts.familyFor(displaySettings.fontId), style = MaterialTheme.typography.labelSmall)
|
||||
Row {
|
||||
FadingTitleBarButton(onClick = { onShowUserListChange(!displaySettings.showUserList) }) {
|
||||
Icon(Icons.Default.Person, contentDescription = "Toggle user list", tint = textColor, modifier = Modifier.size(16.dp))
|
||||
@@ -329,7 +384,7 @@ fun WindowScope.ChannelScreen(
|
||||
FadingTitleBarButton(onClick = { showSettings = !showSettings }) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Window settings", tint = textColor, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
FadingTitleBarButton(onClick = { windowState.isMinimized = true }) {
|
||||
FadingTitleBarButton(onClick = onMinimize) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 10.dp, height = 2.dp)
|
||||
@@ -367,10 +422,13 @@ fun WindowScope.ChannelScreen(
|
||||
Surface {
|
||||
WindowSettingsPanel(
|
||||
displaySettings = displaySettings,
|
||||
textColor = textColor,
|
||||
// Always white: the dialog is black, so the window's own text
|
||||
// color (black, say) would make every label invisible here
|
||||
textColor = Color.White,
|
||||
onAlphaChange = onAlphaChange,
|
||||
onTextColorChange = onTextColorChange,
|
||||
onFontSizeChange = onFontSizeChange,
|
||||
onFontChange = onFontChange,
|
||||
onNickColorsToggle = onNickColorsToggle,
|
||||
onMentionHighlightColorChange = onMentionHighlightColorChange,
|
||||
onShowImagePreviewsChange = onShowImagePreviewsChange
|
||||
@@ -387,7 +445,9 @@ fun WindowScope.ChannelScreen(
|
||||
displaySettings = displaySettings,
|
||||
textColor = textColor,
|
||||
isMention = { isMention(it) },
|
||||
modifier = Modifier.weight(1f).fillMaxHeight()
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
selectionState = scrollbackSelection,
|
||||
onFocusInput = { inputFocusRequester.requestFocus() }
|
||||
)
|
||||
|
||||
// User list (tap and hold for OP actions)
|
||||
@@ -402,7 +462,7 @@ fun WindowScope.ChannelScreen(
|
||||
Text(
|
||||
text = (if (user.isOp) "@" else if (user.isVoice) "+" else "") + user.nick,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = AppFonts.familyFor(displaySettings.fontId),
|
||||
fontSize = displaySettings.fontSize.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -440,7 +500,7 @@ fun WindowScope.ChannelScreen(
|
||||
onValueChange = { input = it },
|
||||
singleLine = true,
|
||||
textStyle = androidx.compose.ui.text.TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = AppFonts.familyFor(displaySettings.fontId),
|
||||
color = textColor,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
),
|
||||
@@ -448,6 +508,9 @@ fun WindowScope.ChannelScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(inputFocusRequester)
|
||||
// Going back to typing ends any scrollback selection, which also
|
||||
// lets its paused auto-scroll catch up.
|
||||
.onFocusChanged { if (it.isFocused) scrollbackSelection.clear() }
|
||||
.onPreviewKeyEvent { event ->
|
||||
// Hardware-keyboard shortcuts: Ctrl+K color picker, Ctrl+B bold,
|
||||
// Ctrl+I italic, Ctrl+V paste (see pasteFromClipboardSafely -
|
||||
@@ -523,18 +586,140 @@ fun WindowScope.ChannelScreen(
|
||||
}
|
||||
|
||||
// Scrollback display shared by channel/PM windows (ChannelScreen) and wIRC main
|
||||
// (MainScreen): the message rows, image previews, line-height snapping and the
|
||||
// stick-to-bottom auto-scroll. Draws no background of its own - callers do.
|
||||
// (MainScreen): the message rows, image previews, line-height snapping, the
|
||||
// stick-to-bottom auto-scroll, and mouse text selection with copy (see
|
||||
// ScrollbackSelectionState). Draws no background of its own - callers do.
|
||||
//
|
||||
// onFocusInput, if given, is called on a plain click in the scrollback and when a
|
||||
// printable key is typed while the scrollback has focus, so the caller can put focus back
|
||||
// in its input field - clicking the scrollback has to focus the scrollback itself first,
|
||||
// or Ctrl+C would never reach it.
|
||||
@Composable
|
||||
fun MessageScrollback(
|
||||
messages: List<IrcMessage>,
|
||||
displaySettings: WindowDisplaySettings,
|
||||
textColor: Color,
|
||||
isMention: (String) -> Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
selectionState: ScrollbackSelectionState = rememberScrollbackSelectionState(),
|
||||
onFocusInput: (() -> Unit)? = null
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val timeFormat = remember { SimpleDateFormat("h:mm", Locale.getDefault()) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// The pointer and key handlers below are installed once and outlive recompositions,
|
||||
// so anything they read that can change between compositions goes through these.
|
||||
val currentMessages by rememberUpdatedState(messages)
|
||||
val currentOnFocusInput by rememberUpdatedState(onFocusInput)
|
||||
|
||||
// (seq, part) -> where that part's text is on screen, for every row currently laid
|
||||
// out. Rows add themselves when composed and remove themselves when disposed, so
|
||||
// this only ever holds the visible rows.
|
||||
val partLayouts = remember { HashMap<Pair<Long, Int>, PartLayout>() }
|
||||
val containerCoords = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||
val scrollbackFocus = remember { FocusRequester() }
|
||||
// Row under the last right-click, for the context menu's "Copy line".
|
||||
var contextMenuRowSeq by remember { mutableStateOf<Long?>(null) }
|
||||
|
||||
fun prefixText(msg: IrcMessage): String =
|
||||
rowPrefix(msg, timeFormat.format(Date(msg.timestampMillis)), textColor, textColor).text
|
||||
|
||||
fun bodyText(msg: IrcMessage): String = parseMircToAnnotatedString(msg.rawText, textColor).text
|
||||
|
||||
// The selected text, one line per message (rows are separate texts on screen, so
|
||||
// without this the copied lines would run together). Built from the message list
|
||||
// rather than from what's on screen, so a selection that's been scrolled partly out of
|
||||
// view still copies in full.
|
||||
fun textInRange(start: TextPos, end: TextPos): String =
|
||||
currentMessages.filter { it.seq in start.seq..end.seq }.joinToString(System.lineSeparator()) { msg ->
|
||||
buildString {
|
||||
listOf(PART_PREFIX to prefixText(msg), PART_BODY to bodyText(msg)).forEach { (part, text) ->
|
||||
partRange(start, end, msg.seq, part, text.length)?.let { append(text, it.first, it.second) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copying clears the selection (as Windows Terminal does), which lets auto-scroll
|
||||
// resume straight away instead of staying paused until the next click.
|
||||
fun copySelection(): Boolean {
|
||||
val sel = selectionState.selection ?: return false
|
||||
val text = textInRange(sel.start, sel.end)
|
||||
selectionState.clear()
|
||||
if (text.isNotEmpty()) copyToClipboardSafely(coroutineScope, text)
|
||||
return true
|
||||
}
|
||||
|
||||
fun copyRow(seq: Long) {
|
||||
val msg = currentMessages.firstOrNull { it.seq == seq } ?: return
|
||||
copyToClipboardSafely(coroutineScope, prefixText(msg) + bodyText(msg))
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
val first = currentMessages.firstOrNull() ?: return
|
||||
val last = currentMessages.last()
|
||||
selectionState.select(TextPos(first.seq, PART_PREFIX, 0), TextPos(last.seq, PART_BODY, bodyText(last).length))
|
||||
}
|
||||
|
||||
// Maps a point in the scrollback (container coordinates) to the nearest character.
|
||||
// The row is the one under the point, or the nearest visible one; a point above or
|
||||
// below that row snaps to its start or end, so dragging past the top or bottom edge
|
||||
// selects whole rows.
|
||||
fun hitTest(p: Offset): TextPos? {
|
||||
val container = containerCoords[0]?.takeIf { it.isAttached } ?: return null
|
||||
class RowBounds(var top: Float, var bottom: Float, var bodyLeft: Float)
|
||||
val rows = HashMap<Long, RowBounds>()
|
||||
for ((key, part) in partLayouts) {
|
||||
val coords = part.coords?.takeIf { it.isAttached } ?: continue
|
||||
if (part.layout == null) continue
|
||||
val b = container.localBoundingBoxOf(coords, clipBounds = false)
|
||||
val row = rows.getOrPut(key.first) { RowBounds(b.top, b.bottom, Float.MAX_VALUE) }
|
||||
row.top = minOf(row.top, b.top)
|
||||
row.bottom = maxOf(row.bottom, b.bottom)
|
||||
if (key.second == PART_BODY) row.bodyLeft = b.left
|
||||
}
|
||||
fun distance(r: RowBounds) = if (p.y < r.top) r.top - p.y else if (p.y >= r.bottom) p.y - r.bottom else 0f
|
||||
val (seq, row) = rows.entries.minByOrNull { distance(it.value) }?.toPair() ?: return null
|
||||
val body = partLayouts[seq to PART_BODY]
|
||||
if (p.y < row.top) return TextPos(seq, PART_PREFIX, 0)
|
||||
if (p.y >= row.bottom) return TextPos(seq, PART_BODY, body?.textLength ?: 0)
|
||||
val part = if (p.x >= row.bodyLeft) PART_BODY else PART_PREFIX
|
||||
val hit = partLayouts[seq to part] ?: return null
|
||||
val layout = hit.layout ?: return null
|
||||
val coords = hit.coords?.takeIf { it.isAttached } ?: return null
|
||||
return TextPos(seq, part, layout.getOffsetForPosition(coords.localPositionOf(container, p)))
|
||||
}
|
||||
|
||||
// The URL under a point, if the point is actually on a link's text (not just level
|
||||
// with it, past the end of the line).
|
||||
fun urlAt(p: Offset): String? {
|
||||
val container = containerCoords[0]?.takeIf { it.isAttached } ?: return null
|
||||
for ((key, part) in partLayouts) {
|
||||
if (key.second != PART_BODY) continue
|
||||
val coords = part.coords?.takeIf { it.isAttached } ?: continue
|
||||
val layout = part.layout ?: continue
|
||||
val local = coords.localPositionOf(container, p)
|
||||
if (local.y < 0 || local.y >= layout.size.height || local.x < 0) continue
|
||||
val line = layout.getLineForVerticalPosition(local.y)
|
||||
if (local.x > layout.getLineRight(line)) return null
|
||||
val offset = layout.getOffsetForPosition(local)
|
||||
return layout.layoutInput.text.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.firstOrNull()?.item
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun wordAt(pos: TextPos): Pair<TextPos, TextPos> {
|
||||
val text = partLayouts[pos.seq to pos.part]?.layout?.layoutInput?.text?.text ?: return pos to pos
|
||||
var start = pos.offset.coerceIn(0, text.length)
|
||||
var end = start
|
||||
while (start > 0 && !text[start - 1].isWhitespace()) start--
|
||||
while (end < text.length && !text[end].isWhitespace()) end++
|
||||
return pos.copy(offset = start) to pos.copy(offset = end)
|
||||
}
|
||||
|
||||
fun rowAt(pos: TextPos): Pair<TextPos, TextPos> =
|
||||
TextPos(pos.seq, PART_PREFIX, 0) to TextPos(pos.seq, PART_BODY, partLayouts[pos.seq to PART_BODY]?.textLength ?: 0)
|
||||
|
||||
// Standard "stick to bottom unless the user scrolled up" chat-client pattern - the
|
||||
// ONLY code in this file that touches scroll position. A previous version of this had
|
||||
@@ -618,15 +803,22 @@ fun MessageScrollback(
|
||||
// there are no visible items yet, or the first visible item's key matches what that
|
||||
// captured list says should be at that index - which can only be true once layout has
|
||||
// processed up through at least that item.
|
||||
//
|
||||
// Paused while the user is selecting text (mid-drag, or a selection is showing), so the
|
||||
// text never moves out from under the mouse. A new message landing meanwhile doesn't
|
||||
// move anything either: reverseLayout keeps the first visible item anchored by key when
|
||||
// one is inserted at index 0. selectionActive is a key so that clearing the selection
|
||||
// relaunches this and catches up to whatever arrived while it was paused.
|
||||
val newestSeq = messages.lastOrNull()?.seq
|
||||
LaunchedEffect(newestSeq) {
|
||||
if (newestSeq != null && stickToBottom && !listState.isScrollInProgress) {
|
||||
val selectionActive = selectionState.isActive
|
||||
LaunchedEffect(newestSeq, selectionActive) {
|
||||
if (newestSeq != null && !selectionActive && stickToBottom && !listState.isScrollInProgress) {
|
||||
val capturedList = reversedMessages
|
||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull() }
|
||||
.first { item ->
|
||||
item == null || (item.index < capturedList.size && item.key == capturedList[item.index].seq)
|
||||
}
|
||||
listState.scrollToItem(0)
|
||||
if (stickToBottom) listState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,14 +849,30 @@ fun MessageScrollback(
|
||||
// then converts that now-stale pixel value using the NEW density, so the
|
||||
// two silently disagreed until the window (and this remember scope) was
|
||||
// recreated by closing and reopening it.
|
||||
val lineHeightPx = remember(displaySettings.fontSize, density) {
|
||||
textMeasurer.measure(
|
||||
text = "Mg",
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
// The buffer text style gets extra vertical air on top of the font's own natural
|
||||
// line height (every bundled font has different metrics, so a fixed multiplier
|
||||
// would loosen some fonts and tighten others). The same style is used to measure
|
||||
// the line height below AND to draw every row, so the whole-line snapping stays
|
||||
// exactly in step with what is on screen. LineHeightStyle with Trim.None and a
|
||||
// centered alignment makes each line exactly lineHeight tall, with the extra
|
||||
// space split above and below the text.
|
||||
val bufferTextStyle = remember(displaySettings.fontSize, displaySettings.fontId, density) {
|
||||
val base = androidx.compose.ui.text.TextStyle(
|
||||
fontFamily = AppFonts.familyFor(displaySettings.fontId),
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
val naturalPx = textMeasurer.measure(text = "Mg", style = base).size.height
|
||||
val spacedLine = with(density) { (naturalPx + (displaySettings.fontSize * LINE_SPACING_EXTRA).sp.toPx()).toSp() }
|
||||
base.copy(
|
||||
lineHeight = spacedLine,
|
||||
lineHeightStyle = androidx.compose.ui.text.style.LineHeightStyle(
|
||||
alignment = androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center,
|
||||
trim = androidx.compose.ui.text.style.LineHeightStyle.Trim.None
|
||||
)
|
||||
).size.height
|
||||
)
|
||||
}
|
||||
val lineHeightPx = remember(bufferTextStyle, density) {
|
||||
textMeasurer.measure(text = "Mg", style = bufferTextStyle).size.height
|
||||
}
|
||||
val lineHeightDp = with(density) { lineHeightPx.toDp() }
|
||||
// Ruled out (see task: newest-message-missing investigation) as the
|
||||
@@ -674,10 +882,14 @@ fun MessageScrollback(
|
||||
// real cause was a race between the scroll effect and the LazyColumn's
|
||||
// own layout catching up to new items - see the scroll effects below.
|
||||
val snappedHeight = if (lineHeightDp > 0.dp) {
|
||||
lineHeightDp * floor(maxHeight / lineHeightDp).toInt()
|
||||
minOf(maxHeight, lineHeightDp * wholeLines(maxHeight, lineHeightDp))
|
||||
} else {
|
||||
maxHeight
|
||||
}
|
||||
// Tells the window how much space is left under the last whole line, so a resize can
|
||||
// trim the window itself to fit (see WindowSnapping.kt)
|
||||
val lineGap = LocalLineGap.current
|
||||
SideEffect { lineGap?.gap = maxHeight - snappedHeight }
|
||||
|
||||
// NOTE: this used to also re-snap the scroll *offset* to the first
|
||||
// visible item on every resize/font-size change, to fix a partial line
|
||||
@@ -691,12 +903,170 @@ fun MessageScrollback(
|
||||
// offset correction through stickToBottom's own effect instead of a
|
||||
// second independent one.
|
||||
|
||||
val textStyle = bufferTextStyle
|
||||
val selection = selectionState.selection
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Dark to match the black windows; the default context menu is light.
|
||||
CompositionLocalProvider(LocalContextMenuRepresentation provides DarkDefaultContextMenuRepresentation) {
|
||||
ContextMenuArea(items = {
|
||||
buildList {
|
||||
val rowSeq = contextMenuRowSeq
|
||||
if (selectionState.selection != null) add(ContextMenuItem("Copy") { copySelection() })
|
||||
else if (rowSeq != null) add(ContextMenuItem("Copy line") { copyRow(rowSeq) })
|
||||
add(ContextMenuItem("Select all") { selectAll() })
|
||||
}
|
||||
}) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(snappedHeight)
|
||||
.onGloballyPositioned { containerCoords[0] = it }
|
||||
.pointerHoverIcon(PointerIcon.Text)
|
||||
// Wheel-up means the user is reading backscroll: stop sticking to the bottom RIGHT NOW,
|
||||
// on the raw wheel event. The scroll-state effect up top can miss a wheel scroll that starts
|
||||
// and finishes inside one frame (isScrollInProgress is never seen as true), which left
|
||||
// stickToBottom set and let the next incoming line yank the buffer back down.
|
||||
// Wheel-down re-checks the resting position shortly after and re-enables sticking if the
|
||||
// user is back at the bottom.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
if (event.type == PointerEventType.Scroll) {
|
||||
val dy = event.changes.firstOrNull()?.scrollDelta?.y ?: 0f
|
||||
if (dy < 0f) {
|
||||
stickToBottom = false
|
||||
} else if (dy > 0f) {
|
||||
coroutineScope.launch {
|
||||
delay(200)
|
||||
stickToBottom = listState.firstVisibleItemIndex == 0 &&
|
||||
listState.firstVisibleItemScrollOffset <= stickToBottomThresholdPx
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remembers the row under a right-click for the menu's "Copy line". Watches the
|
||||
// Initial pass, because ContextMenuArea takes the right-press there, before
|
||||
// the handler below would see it.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) {
|
||||
contextMenuRowSeq = event.changes.firstOrNull()?.let { hitTest(it.position) }?.seq
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
val multiClickMillis =
|
||||
(java.awt.Toolkit.getDefaultToolkit().getDesktopProperty("awt.multiClickInterval") as? Int) ?: 500
|
||||
var lastClickMillis = 0L
|
||||
var lastClickPos = Offset.Zero
|
||||
var clickCount = 0
|
||||
// A link opens only once the double-click interval has passed with no
|
||||
// second click, so double-clicking a link to select it doesn't open it.
|
||||
var pendingLinkOpen: Job? = null
|
||||
awaitEachGesture {
|
||||
// requireUnconsumed: an image preview's own click handler consumes its
|
||||
// press, so clicking an image opens it instead of starting a selection.
|
||||
val down = awaitFirstDown(requireUnconsumed = true)
|
||||
val downEvent = currentEvent
|
||||
if (!downEvent.buttons.isPrimaryPressed) return@awaitEachGesture
|
||||
down.consume()
|
||||
pendingLinkOpen?.cancel()
|
||||
scrollbackFocus.requestFocus()
|
||||
|
||||
val sameSpot = (down.position - lastClickPos).getDistance() <= viewConfiguration.touchSlop
|
||||
clickCount = if (down.uptimeMillis - lastClickMillis <= multiClickMillis && sameSpot) clickCount + 1 else 1
|
||||
lastClickMillis = down.uptimeMillis
|
||||
lastClickPos = down.position
|
||||
val shift = downEvent.keyboardModifiers.isShiftPressed
|
||||
|
||||
// The anchor is what stays selected however far the drag goes: a point
|
||||
// for a single click, the word for a double-click, the row for a triple.
|
||||
val hit = hitTest(down.position)
|
||||
val previousAnchor = selectionState.anchor
|
||||
val anchor = when {
|
||||
hit == null -> null
|
||||
shift && previousAnchor != null -> previousAnchor
|
||||
clickCount == 2 -> wordAt(hit)
|
||||
clickCount >= 3 -> rowAt(hit)
|
||||
else -> hit to hit
|
||||
}
|
||||
selectionState.anchor = anchor
|
||||
fun extendTo(pos: TextPos) {
|
||||
if (anchor != null) selectionState.select(minOf(anchor.first, pos), maxOf(anchor.second, pos))
|
||||
}
|
||||
if (hit != null) extendTo(hit) else selectionState.clear()
|
||||
|
||||
selectionState.dragging = true
|
||||
var moved = false
|
||||
var up: PointerInputChange? = null
|
||||
try {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (!moved && (change.position - down.position).getDistance() > viewConfiguration.touchSlop) {
|
||||
moved = true
|
||||
}
|
||||
// Also on mouse-wheel events mid-drag, so scrolling with the
|
||||
// button held keeps extending the selection.
|
||||
if (moved || event.type == PointerEventType.Scroll) hitTest(change.position)?.let { extendTo(it) }
|
||||
if (event.type != PointerEventType.Scroll) change.consume()
|
||||
if (!change.pressed) {
|
||||
up = change
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
selectionState.dragging = false
|
||||
}
|
||||
|
||||
// A plain single click: open the link under it, or hand focus back to
|
||||
// the input field (what clicking the scrollback did before it was
|
||||
// selectable).
|
||||
if (up != null && !moved && clickCount == 1 && !shift) {
|
||||
selectionState.clear()
|
||||
val url = urlAt(up.position)
|
||||
if (url != null) {
|
||||
pendingLinkOpen = coroutineScope.launch {
|
||||
delay(multiClickMillis.toLong())
|
||||
openUrlInBrowser(url)
|
||||
}
|
||||
} else {
|
||||
currentOnFocusInput?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||
val codePoint = event.utf16CodePoint
|
||||
when {
|
||||
event.isCtrlPressed && (event.key == Key.C || event.key == Key.Insert) -> copySelection()
|
||||
event.isCtrlPressed && event.key == Key.A -> { selectAll(); true }
|
||||
event.key == Key.Escape && selectionState.selection != null -> { selectionState.clear(); true }
|
||||
// Typing while the scrollback has focus moves focus to the input field.
|
||||
// Not consumed: the character that follows goes to the newly focused field.
|
||||
!event.isCtrlPressed && !event.isAltPressed && codePoint in 32..0xFFFE && codePoint != 127 -> {
|
||||
currentOnFocusInput?.invoke()
|
||||
false
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.focusRequester(scrollbackFocus)
|
||||
.focusable()
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
modifier = Modifier
|
||||
.height(snappedHeight)
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 6.dp)
|
||||
) {
|
||||
items(
|
||||
@@ -710,112 +1080,205 @@ fun MessageScrollback(
|
||||
) { msg ->
|
||||
val ts = timeFormat.format(Date(msg.timestampMillis))
|
||||
val bodyAnnotated = parseMircToAnnotatedString(msg.rawText, textColor)
|
||||
val mentioned = isMention(msg.rawText)
|
||||
// Only real messages get the mention tint. Join/part/quit/nick/mode lines
|
||||
// always contain your nick when they're about you ("you joined #chan"),
|
||||
// which used to light up every one of your own joins.
|
||||
val mentioned = msg.kind in MENTION_KINDS && isMention(msg.rawText)
|
||||
val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor
|
||||
val mutedColor = textColor.copy(alpha = 0.7f)
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// Neutralizes the ambient LocalTextStyle so the plain Text() prefix/nick
|
||||
// pieces don't inherit MaterialTheme.typography.bodyLarge's fixed 24sp
|
||||
// lineHeight from the MaterialTheme wrapper in ChannelWindow.kt - without
|
||||
// this, those Texts get a much taller line box than the ClickableText body
|
||||
// below (which uses its own explicit TextStyle and never merges with the
|
||||
// ambient one), and Row's default top alignment then renders the body
|
||||
// visibly offset above its own prefix. The Android version never wraps
|
||||
// ChannelScreen in a MaterialTheme at all, so it never hits this mismatch.
|
||||
CompositionLocalProvider(LocalTextStyle provides androidx.compose.ui.text.TextStyle.Default) {
|
||||
val prefix = rowPrefix(msg, ts, mutedColor, nickColor)
|
||||
|
||||
val prefixLayout = remember { PartLayout() }
|
||||
val bodyLayout = remember { PartLayout() }
|
||||
DisposableEffect(msg.seq) {
|
||||
partLayouts[msg.seq to PART_PREFIX] = prefixLayout
|
||||
partLayouts[msg.seq to PART_BODY] = bodyLayout
|
||||
onDispose {
|
||||
partLayouts.remove(msg.seq to PART_PREFIX)
|
||||
partLayouts.remove(msg.seq to PART_BODY)
|
||||
}
|
||||
}
|
||||
|
||||
// Every row is kept a whole number of lines tall (nearest whole line, to absorb
|
||||
// the pixel or two a wrapped message can be off by), so the line snapping above
|
||||
// stays lined up all the way to the top of the window instead of drifting.
|
||||
Column(modifier = Modifier.fillMaxWidth().snapHeightToLines(lineHeightPx, roundUp = false)) {
|
||||
// Timestamp + nick are one text and the body another, both BasicText with
|
||||
// the same explicit style - BasicText never merges the ambient LocalTextStyle,
|
||||
// so neither picks up MaterialTheme's bodyLarge 24sp lineHeight (which once
|
||||
// rendered the body visibly offset from its prefix). The body is its own text
|
||||
// so a wrapped message stays indented under the body column.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.let { if (mentioned) it.background(Color(displaySettings.mentionHighlightColorArgb)) else it }
|
||||
) {
|
||||
when (msg.kind) {
|
||||
MessageKind.CHAT -> {
|
||||
Text(
|
||||
text = ts,
|
||||
color = mutedColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
Text(
|
||||
text = " ${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}",
|
||||
color = nickColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
}
|
||||
MessageKind.ACTION -> {
|
||||
Text(
|
||||
text = ts,
|
||||
color = mutedColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
Text(
|
||||
text = " * ",
|
||||
color = mutedColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
Text(
|
||||
text = "${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}",
|
||||
color = nickColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
text = ts,
|
||||
color = mutedColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
Text(
|
||||
text = " * ",
|
||||
color = mutedColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
ClickableText(
|
||||
SelectableRowText(
|
||||
text = prefix,
|
||||
style = textStyle,
|
||||
selected = selection?.let { partRange(it.start, it.end, msg.seq, PART_PREFIX, prefix.length) },
|
||||
partLayout = prefixLayout
|
||||
)
|
||||
SelectableRowText(
|
||||
text = bodyAnnotated,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
),
|
||||
onClick = { offset ->
|
||||
bodyAnnotated.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let { annotation ->
|
||||
openUrlInBrowser(annotation.item)
|
||||
}
|
||||
}
|
||||
style = textStyle,
|
||||
selected = selection?.let { partRange(it.start, it.end, msg.seq, PART_BODY, bodyAnnotated.length) },
|
||||
partLayout = bodyLayout
|
||||
)
|
||||
}
|
||||
}
|
||||
if (displaySettings.showImagePreviews) {
|
||||
extractImageUrls(msg.rawText).forEach { url ->
|
||||
UrlImagePreview(
|
||||
url = url,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, top = 2.dp, bottom = 4.dp)
|
||||
.heightIn(max = 200.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
onClick = { openUrlInBrowser(url) }
|
||||
)
|
||||
// A thumbnail can be any height, so its slot is rounded UP to whole lines
|
||||
// (never cropping the picture); otherwise every line above it in the
|
||||
// window would sit a fraction of a line off and show cut in half at the top.
|
||||
Box(modifier = Modifier.snapHeightToLines(lineHeightPx, roundUp = true)) {
|
||||
UrlImagePreview(
|
||||
url = url,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, top = 2.dp, bottom = 4.dp)
|
||||
.heightIn(max = 200.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
onClick = { openUrlInBrowser(url) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extra vertical space added to every buffer line, as a fraction of the font size, on top of
|
||||
// the font's natural line height. 0.30 is a modest loosening; raise it for more air between
|
||||
// lines (0.45 is roomy), lower it for less. Applies to every font in the font list.
|
||||
private const val LINE_SPACING_EXTRA = 0.30f
|
||||
|
||||
// Reports this element's height as a whole number of text lines ([linePx] each): rounded up
|
||||
// for image slots (the picture is never cut), to the nearest line for text rows (a wrapped
|
||||
// message can measure a pixel or two off an exact multiple). Content is drawn from the top.
|
||||
private fun Modifier.snapHeightToLines(linePx: Int, roundUp: Boolean): Modifier = layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints.copy(minHeight = 0))
|
||||
val h = placeable.height
|
||||
val snapped = when {
|
||||
linePx <= 0 || h == 0 -> h
|
||||
roundUp -> ((h + linePx - 1) / linePx) * linePx
|
||||
else -> (((h + linePx / 2) / linePx).coerceAtLeast(1)) * linePx
|
||||
}.coerceIn(constraints.minHeight, constraints.maxHeight.coerceAtLeast(constraints.minHeight))
|
||||
layout(placeable.width, snapped) { placeable.place(0, 0) }
|
||||
}
|
||||
|
||||
// Which of a row's two texts a TextPos is in: the timestamp/nick prefix, or the body.
|
||||
private const val PART_PREFIX = 0
|
||||
private const val PART_BODY = 1
|
||||
|
||||
// A point in the scrollback's text: the message (by seq, which follows message order),
|
||||
// which of its two texts, and a character offset into that text.
|
||||
data class TextPos(val seq: Long, val part: Int, val offset: Int) : Comparable<TextPos> {
|
||||
override fun compareTo(other: TextPos): Int =
|
||||
compareValuesBy(this, other, { it.seq }, { it.part }, { it.offset })
|
||||
}
|
||||
|
||||
// A non-empty selection, start before end.
|
||||
data class TextSelection(val start: TextPos, val end: TextPos)
|
||||
|
||||
/**
|
||||
* Text selection in a MessageScrollback. Hoisted so a caller can clear it - ChannelScreen
|
||||
* does when its input field gains focus, which also lets the paused auto-scroll catch up.
|
||||
*/
|
||||
class ScrollbackSelectionState {
|
||||
var selection by mutableStateOf<TextSelection?>(null)
|
||||
private set
|
||||
|
||||
// True while the mouse button is held down in the scrollback.
|
||||
var dragging by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
// The word/row/point the current selection was started from - see MessageScrollback.
|
||||
internal var anchor: Pair<TextPos, TextPos>? = null
|
||||
|
||||
// Auto-scroll stays paused while this is true.
|
||||
val isActive: Boolean get() = dragging || selection != null
|
||||
|
||||
fun select(start: TextPos, end: TextPos) {
|
||||
selection = if (start < end) TextSelection(start, end) else null
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
selection = null
|
||||
anchor = null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberScrollbackSelectionState(): ScrollbackSelectionState = remember { ScrollbackSelectionState() }
|
||||
|
||||
// Where one row text was last laid out, kept up to date by SelectableRowText.
|
||||
private class PartLayout {
|
||||
var coords: LayoutCoordinates? = null
|
||||
var layout: TextLayoutResult? = null
|
||||
val textLength: Int get() = layout?.layoutInput?.text?.length ?: 0
|
||||
}
|
||||
|
||||
// The part of [start, end) that falls inside one row text, as a character range into it.
|
||||
private fun partRange(start: TextPos, end: TextPos, seq: Long, part: Int, length: Int): Pair<Int, Int>? {
|
||||
val from = maxOf(start, TextPos(seq, part, 0))
|
||||
val to = minOf(end, TextPos(seq, part, length))
|
||||
return if (from < to) from.offset to to.offset else null
|
||||
}
|
||||
|
||||
private val SELECTION_COLOR = Color(0xFF3399FF).copy(alpha = 0.45f)
|
||||
|
||||
// A row text that records its layout for hit-testing and draws the selection behind
|
||||
// itself. The highlight is drawn rather than added as a background span, so changing the
|
||||
// selection only redraws - it never re-measures the text.
|
||||
@Composable
|
||||
private fun SelectableRowText(
|
||||
text: AnnotatedString,
|
||||
style: androidx.compose.ui.text.TextStyle,
|
||||
selected: Pair<Int, Int>?,
|
||||
partLayout: PartLayout
|
||||
) {
|
||||
var layout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
BasicText(
|
||||
text = text,
|
||||
style = style,
|
||||
onTextLayout = {
|
||||
layout = it
|
||||
partLayout.layout = it
|
||||
},
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned { partLayout.coords = it }
|
||||
.drawBehind {
|
||||
val l = layout
|
||||
if (selected != null && l != null && selected.second <= l.layoutInput.text.length) {
|
||||
drawPath(l.getPathForRange(selected.first, selected.second), SELECTION_COLOR)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// The timestamp + nick (or " * ") prefix of a row, styled as it's shown.
|
||||
private fun rowPrefix(msg: IrcMessage, ts: String, mutedColor: Color, nickColor: Color): AnnotatedString =
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = mutedColor, fontWeight = FontWeight.Bold)) { append(ts) }
|
||||
when (msg.kind) {
|
||||
MessageKind.CHAT -> withStyle(SpanStyle(color = nickColor)) {
|
||||
append(" ${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}")
|
||||
}
|
||||
MessageKind.ACTION -> {
|
||||
withStyle(SpanStyle(color = mutedColor)) { append(" * ") }
|
||||
withStyle(SpanStyle(color = nickColor)) { append("${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}") }
|
||||
}
|
||||
else -> withStyle(SpanStyle(color = mutedColor)) { append(" * ") }
|
||||
}
|
||||
}
|
||||
|
||||
// A 28dp title-bar IconButton that rests invisible and fades in while hovered. The fade
|
||||
// is a graphicsLayer alpha only - the button is always laid out, drawn and hit-tested, so
|
||||
// it still gets hover and clicks at alpha 0. The alpha is read inside the graphicsLayer
|
||||
@@ -1015,6 +1478,8 @@ private fun MentionColorPickerDialog(currentArgb: Int, onDismiss: () -> Unit, on
|
||||
)
|
||||
}
|
||||
|
||||
private val MENTION_KINDS = setOf(MessageKind.CHAT, MessageKind.ACTION, MessageKind.NOTICE)
|
||||
|
||||
@Composable
|
||||
private fun WindowSettingsPanel(
|
||||
displaySettings: WindowDisplaySettings,
|
||||
@@ -1022,6 +1487,7 @@ private fun WindowSettingsPanel(
|
||||
onAlphaChange: (Float) -> Unit,
|
||||
onTextColorChange: (Int) -> Unit,
|
||||
onFontSizeChange: (Float) -> Unit,
|
||||
onFontChange: (String) -> Unit,
|
||||
onNickColorsToggle: (Boolean) -> Unit,
|
||||
onMentionHighlightColorChange: (Int) -> Unit,
|
||||
onShowImagePreviewsChange: (Boolean) -> Unit
|
||||
@@ -1051,13 +1517,27 @@ private fun WindowSettingsPanel(
|
||||
colors = sliderColors
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Font size", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||
Slider(
|
||||
value = displaySettings.fontSize,
|
||||
onValueChange = onFontSizeChange,
|
||||
valueRange = 10f..24f,
|
||||
colors = sliderColors
|
||||
)
|
||||
Text("Font size:", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||
// Fixed sizes 10 to 18 (same as QuickClaude's Font size menu), square boxes in a row
|
||||
val currentSize = displaySettings.fontSize.roundToInt()
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
for (size in MIN_FONT_SIZE..MAX_FONT_SIZE) {
|
||||
val selected = size == currentSize
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.background(if (selected) Color.White.copy(alpha = 0.25f) else Color.Transparent)
|
||||
.border(1.dp, if (selected) Color.White else Color.White.copy(alpha = 0.35f))
|
||||
.clickableNoRipple { onFontSizeChange(size.toFloat()) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(size.toString(), color = textColor, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Font:", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||
FontPickerList(displaySettings.fontId, textColor, onFontChange)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Text color:", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||
FlowSwatchGrid(
|
||||
|
||||
@@ -3,6 +3,7 @@ package ui
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -20,9 +21,20 @@ import data.WindowPrefsRepository
|
||||
import data.isChannelName
|
||||
import data.storageId
|
||||
import irc.ConnectionManager
|
||||
import irc.IrcConnection
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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
|
||||
* top-level OS window, same intent as ChannelWindowActivity.kt's per-channel Activity on
|
||||
@@ -34,9 +46,11 @@ fun ChannelWindow(
|
||||
bufferKey: BufferKey,
|
||||
prefsRepo: WindowPrefsRepository,
|
||||
colorScheme: ColorScheme,
|
||||
visible: Boolean,
|
||||
onHide: () -> Unit,
|
||||
onOpenBuffer: (BufferKey) -> Unit,
|
||||
onCloseSelf: () -> Unit,
|
||||
onQuitApp: () -> Unit
|
||||
onDisconnectServer: () -> Unit
|
||||
) {
|
||||
val serverId = bufferKey.serverId
|
||||
val channelName = bufferKey.name
|
||||
@@ -63,14 +77,20 @@ fun ChannelWindow(
|
||||
// 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
|
||||
// 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
|
||||
// OS chrome would otherwise provide. No Surface wrapper here (M3 Surface paints an
|
||||
// opaque background) - ChannelScreen already paints its own alpha-aware background.
|
||||
// Hidden to the tray when [visible] is false - the window and its composition stay
|
||||
// alive, so its buffer keeps filling while hidden.
|
||||
Window(
|
||||
onCloseRequest = onCloseSelf,
|
||||
visible = visible,
|
||||
title = windowTitle,
|
||||
icon = painterResource("icon.png"),
|
||||
state = windowState,
|
||||
@@ -78,16 +98,30 @@ fun ChannelWindow(
|
||||
transparent = true,
|
||||
resizable = true
|
||||
) {
|
||||
TrayWindowBehavior(windowState, onHide)
|
||||
CompositionLocalProvider(LocalLineGap provides lineGap) {
|
||||
MaterialTheme(colorScheme = colorScheme) {
|
||||
ChannelScreen(
|
||||
windowState = windowState,
|
||||
onMinimize = onHide,
|
||||
onCloseWindow = onCloseSelf,
|
||||
channelName = windowTitle,
|
||||
buffer = buffer,
|
||||
displaySettings = displaySettings,
|
||||
colorScheme = colorScheme,
|
||||
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("/")) {
|
||||
val withoutSlash = text.substring(1)
|
||||
val spaceIdx = withoutSlash.indexOf(' ')
|
||||
@@ -112,24 +146,24 @@ fun ChannelWindow(
|
||||
"NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args")
|
||||
"PART", "EXIT", "CLOSE" -> {
|
||||
val target = args.ifBlank { channelName }
|
||||
ConnectionManager.connectionFor(serverId)?.partChannel(target, "leaving")
|
||||
ConnectionManager.partChannel(serverId, target, "leaving")
|
||||
if (args.isBlank() || target == channelName) onCloseSelf()
|
||||
}
|
||||
"QUIT" -> {
|
||||
if (rawArgs.isBlank()) {
|
||||
ConnectionManager.disconnectAll()
|
||||
onQuitApp()
|
||||
// Disconnects this window's server only - every
|
||||
// window stays open; see ConnectionManager.disconnect.
|
||||
onDisconnectServer()
|
||||
} else {
|
||||
ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs)
|
||||
}
|
||||
}
|
||||
"JOIN" -> {
|
||||
ConnectionManager.sendRaw(serverId, "JOIN $args")
|
||||
ConnectionManager.joinChannels(serverId, args)
|
||||
onOpenBuffer(BufferKey(serverId, args))
|
||||
}
|
||||
// Reply lines come back to this window, or to wIRC main if
|
||||
// this window is closed before they arrive.
|
||||
"WHOIS" -> ConnectionManager.whois(serverId, args, origin = bufferKey)
|
||||
// All whois output goes to wIRC main, not this window.
|
||||
"WHOIS" -> ConnectionManager.whois(serverId, args)
|
||||
"MSG" -> {
|
||||
val msgSpaceIdx = args.indexOf(' ')
|
||||
if (msgSpaceIdx >= 0) {
|
||||
@@ -145,10 +179,10 @@ fun ChannelWindow(
|
||||
ConnectionManager.sendChannelMessage(serverId, channelName, text)
|
||||
}
|
||||
},
|
||||
onKick = { nick -> ConnectionManager.connectionFor(serverId)?.kick(channelName, nick) },
|
||||
onBan = { nick -> ConnectionManager.connectionFor(serverId)?.ban(channelName, "$nick!*@*") },
|
||||
onOp = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "+o", nick) },
|
||||
onDeop = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "-o", nick) },
|
||||
onKick = { nick -> withConnection(serverId, channelName) { it.kick(channelName, nick) } },
|
||||
onBan = { nick -> withConnection(serverId, channelName) { it.ban(channelName, "$nick!*@*") } },
|
||||
onOp = { nick -> withConnection(serverId, channelName) { it.setMode(channelName, "+o", nick) } },
|
||||
onDeop = { nick -> withConnection(serverId, channelName) { it.setMode(channelName, "-o", nick) } },
|
||||
onAlphaChange = { alpha ->
|
||||
scope.launch(Dispatchers.IO) { prefsRepo.setAlpha(bufferKey.storageId(), alpha) }
|
||||
},
|
||||
@@ -158,6 +192,9 @@ fun ChannelWindow(
|
||||
onFontSizeChange = { size ->
|
||||
scope.launch(Dispatchers.IO) { prefsRepo.setFontSize(bufferKey.storageId(), size) }
|
||||
},
|
||||
onFontChange = { id ->
|
||||
scope.launch(Dispatchers.IO) { prefsRepo.setFontId(bufferKey.storageId(), id) }
|
||||
},
|
||||
onShowUserListChange = { show ->
|
||||
scope.launch(Dispatchers.IO) { prefsRepo.setShowUserList(bufferKey.storageId(), show) }
|
||||
},
|
||||
@@ -172,5 +209,6 @@ fun ChannelWindow(
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,25 +41,55 @@ fun MainScreen(
|
||||
repo: ServerRepository,
|
||||
colorScheme: ColorScheme,
|
||||
displaySettings: WindowDisplaySettings,
|
||||
autoConnectServerIds: Set<String>,
|
||||
onOpenChannel: (serverId: String, channelName: String) -> Unit,
|
||||
onExitApp: () -> Unit,
|
||||
onUnreadChange: (Int) -> Unit
|
||||
onUnreadChange: (Int) -> Unit,
|
||||
onFontChange: (String) -> Unit
|
||||
) {
|
||||
var showFontDialog by remember { mutableStateOf(false) }
|
||||
var servers by remember { mutableStateOf(repo.loadAll()) }
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
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 openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
fun connectServer(server: ServerConfig) {
|
||||
// Also rejoins the channels this server was in before a /quit - their
|
||||
// windows are still open, and opening one that already is is a no-op.
|
||||
ConnectionManager.connect(server)
|
||||
if (server.autoJoinChannels.isNotEmpty()) {
|
||||
// Auto-join sends JOIN correctly on connect (see IrcConnection),
|
||||
// but nothing else opens a window for it like the manual
|
||||
// "Join + Open" flow does - so give the server a moment to
|
||||
// actually join us before opening windows for them.
|
||||
coroutineScope.launch {
|
||||
delay(500)
|
||||
server.autoJoinChannels.forEach { ch ->
|
||||
val current = openedChannels[server.id] ?: emptyList()
|
||||
if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch))
|
||||
onOpenChannel(server.id, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Servers to connect at startup, as if Connect had been clicked (see Main.kt)
|
||||
LaunchedEffect(Unit) {
|
||||
servers.filter { it.id in autoConnectServerIds }.forEach { connectServer(it) }
|
||||
}
|
||||
|
||||
val buffers by ConnectionManager.buffers.collectAsState()
|
||||
val mainMessages by remember { derivedStateOf { buffers[MAIN_BUFFER_KEY]?.messages ?: emptyList() } }
|
||||
val textColor = mircPalette[displaySettings.textColorMircIndex]
|
||||
|
||||
// Unread indicator: lines that arrived in wIRC main while this window didn't have focus
|
||||
// (e.g. while typing in a channel window). Shown next to the "Server messages" label and,
|
||||
// via onUnreadChange, in the window title / taskbar. Cleared as soon as the window is
|
||||
// via onUnreadChange, in the window title and the tray tooltip. Cleared as soon as the window is
|
||||
// focused. Compared by timestamp rather than count so history loaded at startup never
|
||||
// counts as new.
|
||||
val windowFocused = LocalWindowInfo.current.isWindowFocused
|
||||
@@ -85,9 +115,14 @@ fun MainScreen(
|
||||
Text("+ Add Server")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// Same end result as typing "/quit" alone in a channel window - disconnects
|
||||
// every server and terminates the process - just reachable without needing
|
||||
// a channel window open first.
|
||||
// Font for the server messages box (channel windows pick theirs in their own
|
||||
// settings).
|
||||
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) {
|
||||
Text("Exit wIRC")
|
||||
}
|
||||
@@ -107,24 +142,7 @@ fun MainScreen(
|
||||
channelInput = channelInputs[server.id] ?: "#",
|
||||
openedChannels = openedChannels[server.id] ?: emptyList(),
|
||||
onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) },
|
||||
onConnect = {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onConnect = { connectServer(server) },
|
||||
onEdit = { editingServer = server; showAddDialog = true },
|
||||
onDelete = {
|
||||
repo.remove(server.id)
|
||||
@@ -168,8 +186,9 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// MOTD, numerics, errors, services/server notices and WHOIS replies whose window
|
||||
// is gone - see ConnectionManager. Same rendering and auto-scroll as a channel window.
|
||||
// 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,
|
||||
@@ -184,6 +203,25 @@ fun MainScreen(
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
ServerEditDialog(
|
||||
existing = editingServer,
|
||||
@@ -242,6 +280,8 @@ private fun ServerCard(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (!isConnected) {
|
||||
Text("Disconnected", style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Button(onClick = onConnect) { Text("Connect") }
|
||||
} else {
|
||||
Text("Connected", style = MaterialTheme.typography.bodySmall)
|
||||
|
||||
@@ -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