3 Commits
Author SHA1 Message Date
weishauptandClaude Opus 5.5 efad80e141 Add font picker (Segoe UI, Tahoma, Tw Cen MT, Nunito, Quicksand, Varela Round), bump to 0.1.10
Each channel window's settings panel gets a Font list, and wIRC main gets a Font
button that opens the same list for the server messages box. Every name is drawn
in its own font as a preview. The choice is saved per window in window_prefs.json
(fontId) and applies to the scrollback, user list, input box and title.

- Segoe UI, Tahoma and Tw Cen MT are Microsoft-licensed, so they aren't bundled:
  they load from the Windows font folders at runtime and are only listed when
  present (Tw Cen MT comes with Office).
- Nunito, Quicksand and Varela Round are SIL OFL fonts, bundled in
  resources/fonts with their license files.
- A missing or unloadable font falls back to Monospace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:18:31 -04:00
weishauptandClaude Opus 5.5 3538274805 Make bare /quit disconnect only that server, keep windows open, bump to 0.1.9
/quit now disconnects the window's server without closing anything: every
buffer of that server and wIRC main get a "Disconnected" line, the server card
shows Disconnected with its Connect button, history is flushed to disk, and
there's no auto-reconnect. Connect rejoins the channels we were in (including
ones joined with /join, now tracked) into the still-open windows. Sending while
disconnected shows a "Not connected" line in that window. Exit wIRC still
disconnects everything and closes the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:46:59 -04:00
weishauptandClaude Opus 5.5 d9a0297cc7 Add scrollback text selection/copy, route all /whois output to wIRC main, bump to 0.1.8
- Scrollback (channel windows and wIRC main): mouse drag selection, double-click word,
  triple-click line, shift-click extend, Ctrl+C / Ctrl+Insert, Ctrl+A, Esc, and a
  right-click menu (Copy / Copy line / Select all). Copies one line per message.
  Clipboard writes go off the UI thread with a timeout, like the paste handler.
  Auto-scroll pauses while selecting and catches up once the selection is cleared.
- /whois: header, every reply line, related numerics (401/402/716/263...) and server or
  services notices arriving right after it all go to wIRC main under a bold
  "===== WHOIS <nick> (<server>) =====" header, counted by its unread indicator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:07:52 -04:00
19 changed files with 1174 additions and 161 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ plugins {
} }
group = "com.tpuahsiew.wirc" group = "com.tpuahsiew.wirc"
version = "0.1.7" version = "0.1.10"
// No project-level repositories{} block here: settings.gradle.kts sets // No project-level repositories{} block here: settings.gradle.kts sets
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already // dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
@@ -38,7 +38,7 @@ compose.desktop {
appResourcesRootDir.set(project.file("installerResources")) appResourcesRootDir.set(project.file("installerResources"))
targetFormats(TargetFormat.Msi, TargetFormat.Exe) targetFormats(TargetFormat.Msi, TargetFormat.Exe)
packageName = "wIRC" packageName = "wIRC"
packageVersion = "0.1.7" packageVersion = "0.1.10"
windows { windows {
menuGroup = "wIRC" menuGroup = "wIRC"
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a" upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
+9 -12
View File
@@ -58,15 +58,9 @@ fun main() = application {
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) } var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
var mainUnread by remember { mutableStateOf(0) } var mainUnread by remember { mutableStateOf(0) }
// ConnectionManager routes WHOIS replies to the window that asked only while that // Full shutdown for the "Exit wIRC" button: disconnect every server (which also writes
// window is still open, so it needs to know which ones are. // all scrollback to disk), then end the process. A bare /quit in a channel window only
LaunchedEffect(openBuffers) { // disconnects that window's server - see ConnectionManager.disconnect.
ConnectionManager.openWindows = openBuffers
}
// 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.
val shutdownApp: () -> Unit = { val shutdownApp: () -> Unit = {
ConnectionManager.disconnectAll() ConnectionManager.disconnectAll()
exitApplication() exitApplication()
@@ -114,7 +108,8 @@ fun main() = application {
openBuffers = openBuffers + BufferKey(serverId, channelName) openBuffers = openBuffers + BufferKey(serverId, channelName)
}, },
onExitApp = shutdownApp, onExitApp = shutdownApp,
onUnreadChange = { mainUnread = it } onUnreadChange = { mainUnread = it },
onFontChange = { id -> prefsRepo.setFontId(MAIN_BUFFER_KEY.storageId(), id) }
) )
} }
} }
@@ -128,8 +123,10 @@ fun main() = application {
colorScheme = neutralDarkScheme, colorScheme = neutralDarkScheme,
onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey }, onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey },
onCloseSelf = { openBuffers = openBuffers - bufferKey }, onCloseSelf = { openBuffers = openBuffers - bufferKey },
onQuitApp = { // openBuffers is read when /quit runs, so every window open at that moment
exitApplication() // gets its "Disconnected" line, even one that hasn't had a line yet.
onDisconnectServer = {
ConnectionManager.disconnect(bufferKey.serverId, openBuffers)
} }
) )
} }
+6
View File
@@ -50,6 +50,12 @@ object AliasRepository {
return aliases.size return aliases.size
} }
/** True if [command] (without the leading '/') is an alias - those run locally, no server needed. */
fun hasAlias(command: String): Boolean {
val key = command.lowercase()
return key != RESERVED_RELOAD && key in aliases
}
/** /**
* Returns true if [command] (without the leading '/') matched a known alias and its * Returns true if [command] (without the leading '/') matched a known alias and its
* action was run - callers should treat that as "handled, don't fall through to IRC * action was run - callers should treat that as "handled, don't fall through to IRC
+1 -1
View File
@@ -48,7 +48,7 @@ data class IrcMessage(
data class BufferKey(val serverId: String, val name: String) data class BufferKey(val serverId: String, val name: String)
/** /**
* wIRC main's own buffer: server messages (MOTD, numerics, errors, 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 * 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 * 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 * never contain '-' - this id can't collide with a real server. Also used as the
@@ -14,6 +14,7 @@ data class WindowDisplaySettings(
val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque
val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white
val fontSize: Float = 14f, val fontSize: Float = 14f,
val fontId: String = "monospace", // see ui/AppFonts.kt; unknown/missing ids fall back to monospace
val showUserList: Boolean = true, val showUserList: Boolean = true,
val mentionHighlightColorArgb: Int = 0x40FFA500.toInt(), // translucent orange val mentionHighlightColorArgb: Int = 0x40FFA500.toInt(), // translucent orange
val nickColorsEnabled: Boolean = true, val nickColorsEnabled: Boolean = true,
@@ -64,6 +65,8 @@ class WindowPrefsRepository(rootDir: File) {
fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = fontSize) } fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = fontSize) }
fun setFontId(bufferKeyId: String, fontId: String) = update(bufferKeyId) { it.copy(fontId = fontId) }
fun setShowUserList(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showUserList = show) } fun setShowUserList(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showUserList = show) }
fun setMentionHighlightColor(bufferKeyId: String, colorArgb: Int) = update(bufferKeyId) { it.copy(mentionHighlightColorArgb = colorArgb) } fun setMentionHighlightColor(bufferKeyId: String, colorArgb: Int) = update(bufferKeyId) { it.copy(mentionHighlightColorArgb = colorArgb) }
@@ -107,6 +110,7 @@ class WindowPrefsRepository(rootDir: File) {
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(), backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
textColorMircIndex = o.optInt("textColorMircIndex", 0), textColorMircIndex = o.optInt("textColorMircIndex", 0),
fontSize = o.optDouble("fontSize", 14.0).toFloat(), fontSize = o.optDouble("fontSize", 14.0).toFloat(),
fontId = o.optString("fontId", "monospace"),
showUserList = o.optBoolean("showUserList", true), showUserList = o.optBoolean("showUserList", true),
mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(), mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(),
nickColorsEnabled = o.optBoolean("nickColorsEnabled", true), nickColorsEnabled = o.optBoolean("nickColorsEnabled", true),
@@ -138,6 +142,7 @@ class WindowPrefsRepository(rootDir: File) {
put("backgroundAlpha", settings.backgroundAlpha) put("backgroundAlpha", settings.backgroundAlpha)
put("textColorMircIndex", settings.textColorMircIndex) put("textColorMircIndex", settings.textColorMircIndex)
put("fontSize", settings.fontSize) put("fontSize", settings.fontSize)
put("fontId", settings.fontId)
put("showUserList", settings.showUserList) put("showUserList", settings.showUserList)
put("mentionHighlightColorArgb", settings.mentionHighlightColorArgb) put("mentionHighlightColorArgb", settings.mentionHighlightColorArgb)
put("nickColorsEnabled", settings.nickColorsEnabled) put("nickColorsEnabled", settings.nickColorsEnabled)
+186 -33
View File
@@ -33,12 +33,22 @@ private const val SAVE_DEBOUNCE_MS = 2500L
// WHOIS reply numerics. Each one's params are [ourNick, targetNick, ...], so the target // 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. // 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( private val WHOIS_NUMERICS = setOf(
"275", "276", "301", "307", "310", "311", "312", "313", "317", "318", "319", "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 // 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 - // (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. // 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()) private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
// Buffers that currently have a window open, kept up to date by Main.kt. Read when a // Servers that have a live connection (or one that's connecting/auto-reconnecting after
// WHOIS reply arrives, to decide whether the window that asked is still there to show it. // a network drop). A server leaves this set only through disconnect()/disconnectAll(),
@Volatile // and it's what wIRC main's server cards show as Connected / Disconnected.
var openWindows: Set<BufferKey> = emptySet() private val _connectedIds = MutableStateFlow<Set<String>>(emptySet())
val connectedIds: StateFlow<Set<String>> = _connectedIds
// serverId -> (lowercased target nick -> the buffer whose window typed the /whois). // serverId -> channels we were in when /quit disconnected that server, so the next
private val pendingWhois = ConcurrentHashMap<String, ConcurrentHashMap<String, BufferKey>>() // connect() rejoins them (and the windows still open for them pick right back up)
// instead of only the configured auto-join list.
private val channelsToRejoin = ConcurrentHashMap<String, MutableSet<String>>()
// A /whois we sent and haven't seen the end of yet. headerShown tracks whether its
// "WHOIS <nick>" header line is already in wIRC main.
private class PendingWhois(val nick: String, val sentAtMillis: Long, var headerShown: Boolean)
// serverId -> (lowercased target nick -> pending whois). Only ever touched from the
// bufferOps consumer coroutine (see enqueue), so a plain map is enough.
private val pendingWhois = HashMap<String, MutableMap<String, PendingWhois>>()
fun init(rootDir: File) { fun init(rootDir: File) {
historyRepo = MessageHistoryRepository(rootDir) historyRepo = MessageHistoryRepository(rootDir)
@@ -132,33 +153,100 @@ object ConnectionManager {
existing.disconnect() existing.disconnect()
} }
setOwnNick(config.id, config.nick) setOwnNick(config.id, config.nick)
val conn = IrcConnection( // Channels from the connection a /quit ended, or from the one being replaced, so
// they're rejoined along with the auto-join list.
val rejoin = channelsToRejoin.remove(config.id).orEmpty() + existing?.trackedChannels().orEmpty()
lateinit var conn: IrcConnection
conn = IrcConnection(
config = config, config = config,
scope = scope, scope = scope,
onLine = { line -> enqueue { handleLine(config.id, line) } }, initialChannels = rejoin,
// 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 -> onStatus = { msg ->
if (connections[config.id] === conn) {
_statuses.update { it + (config.id to msg) } _statuses.update { it + (config.id to msg) }
// Connection progress and errors aren't tied to any channel - wIRC main. // Connection progress and errors aren't tied to any channel - wIRC main.
enqueue { addMainLine(MessageKind.SYSTEM, null, "[${config.label}] $msg") } enqueue { addMainLine(MessageKind.SYSTEM, null, "[${config.label}] $msg") }
} }
}
) )
connections[config.id] = conn connections[config.id] = conn
_connectedIds.update { it + config.id }
conn.connect() conn.connect()
} }
/**
* Bare /quit: disconnects this one server and nothing else. Windows stay open and keep
* their scrollback; every buffer of this server (plus [openKeys], the open windows, in
* case one hasn't had a line yet) gets a "Disconnected" line, and so does wIRC main.
* There's no auto-reconnect afterward - the next connect() (the server card's Connect
* button) rejoins the channels we were in. History is written to disk right away, the
* same as disconnectAll() does on exit.
*/
fun disconnect(serverId: String, openKeys: Collection<BufferKey> = emptyList()) {
val conn = connections.remove(serverId) ?: return
conn.disconnect()
channelsToRejoin[serverId] = conn.trackedChannels().toMutableSet()
_connectedIds.update { it - serverId }
_statuses.update { it + (serverId to "Disconnected") }
val label = conn.config.label
enqueue {
pendingWhois.remove(serverId)
val now = System.currentTimeMillis()
val keys = _buffers.value.keys.filter { it.serverId == serverId } +
openKeys.filter { it.serverId == serverId }
keys.distinct().forEach { key ->
addMessage(key, IrcMessage(now, MessageKind.SYSTEM, null, "Disconnected from $label"))
}
// We're no longer in any channel, so the user lists are stale - the NAMES reply
// on rejoin fills them again.
_buffers.update { map ->
map.mapValues { (key, buf) -> if (key.serverId == serverId) buf.copy(users = emptyList()) else buf }
}
addMainLine(MessageKind.SYSTEM, null, "[$label] Disconnected")
// Runs on the bufferOps queue after the lines above, so they're in the save.
flushAllSaves()
}
}
fun disconnectAll() { fun disconnectAll() {
connections.values.forEach { it.disconnect() } connections.values.forEach { it.disconnect() }
connections.clear() connections.clear()
_connectedIds.value = emptySet()
flushAllSaves() flushAllSaves()
} }
fun isConnected(serverId: String): Boolean = connections.containsKey(serverId)
// /part from a channel window. While disconnected there's no PART to send, but the
// channel is dropped from the rejoin list so reconnecting doesn't rejoin it.
fun partChannel(serverId: String, channel: String, reason: String) {
val conn = connectionFor(serverId)
if (conn != null) conn.partChannel(channel, reason)
else channelsToRejoin[serverId]?.remove(channel)
}
// /join from a channel window. A plain channel list is tracked (see
// IrcConnection.joinChannel) so a reconnect rejoins it; one with a key is sent as-is,
// since rejoining it later without the key wouldn't work anyway.
fun joinChannels(serverId: String, args: String) {
val conn = connectionFor(serverId) ?: return
if (args.contains(' ')) conn.send("JOIN $args")
else args.split(',').filter { it.isNotBlank() }.forEach { conn.joinChannel(it) }
}
fun connectionFor(serverId: String): IrcConnection? = connections[serverId] fun connectionFor(serverId: String): IrcConnection? = connections[serverId]
// IRC servers don't echo PRIVMSG back to its sender, so a sent message has to be // IRC servers don't echo PRIVMSG back to its sender, so a sent message has to be
// added to our own buffer locally - otherwise it would never show up in the window // added to our own buffer locally - otherwise it would never show up in the window
// that sent it. // that sent it.
fun sendChannelMessage(serverId: String, target: String, text: String) { fun sendChannelMessage(serverId: String, target: String, text: String) {
connectionFor(serverId)?.sendMessage(target, text) // No local echo while disconnected - the line was never sent.
val conn = connectionFor(serverId) ?: return
conn.sendMessage(target, text)
val myNick = ownNicks[serverId] ?: return val myNick = ownNicks[serverId] ?: return
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text) val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
enqueue { addOutgoing(serverId, target, msg, "-> *$target* $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 // CTCP ACTION wrapper, while the buffer should just hold the plain action text
// (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix). // (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix).
fun sendChannelAction(serverId: String, target: String, action: String) { fun sendChannelAction(serverId: String, target: String, action: String) {
connectionFor(serverId)?.sendMessage(target, "\u0001ACTION $action\u0001") val conn = connectionFor(serverId) ?: return
conn.sendMessage(target, "\u0001ACTION $action\u0001")
val myNick = ownNicks[serverId] ?: return val myNick = ownNicks[serverId] ?: return
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action) val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
enqueue { addOutgoing(serverId, target, msg, "-> *$target* * $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 * Sends WHOIS. All of its output - the header, every reply line, and any server or
* window - or in wIRC main if it has been closed by the time they arrive. [args] is * services notice that comes back in response - goes to wIRC main, whichever window the
* whatever followed "/whois"; its last word is the nick being looked up (the * command was typed in. [args] is whatever followed "/whois"; its last word is the nick
* "/whois server nick" form puts the nick last too). * 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) { fun whois(serverId: String, args: String) {
val nick = args.trim().split(Regex("\\s+")).lastOrNull()?.takeIf { it.isNotEmpty() } val label = connectionFor(serverId)?.config?.label
if (nick == null) { val nicks = args.trim().split(Regex("\\s+")).lastOrNull()
addSystemMessage(serverId, origin.name, "Usage: /whois <nick>") ?.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 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()}") sendRaw(serverId, "WHOIS ${args.trim()}")
} }
private fun showWhoisHeader(serverId: String, pending: PendingWhois) {
if (pending.headerShown) return
pending.headerShown = true
val label = connectionFor(serverId)?.config?.label ?: serverId
// \u0002 is the mIRC bold toggle, so the header stands out from the reply lines.
addMainLine(MessageKind.SYSTEM, null, "\u0002===== WHOIS ${pending.nick} ($label) =====\u0002")
}
// Pending whois for this server, with any that timed out dropped first.
private fun livePendingWhois(serverId: String): MutableMap<String, PendingWhois>? {
val pending = pendingWhois[serverId] ?: return null
val cutoff = System.currentTimeMillis() - WHOIS_PENDING_TIMEOUT_MS
pending.values.removeAll { it.sentAtMillis < cutoff }
return pending
}
// A numeric is whois output if it's one of WHOIS_NUMERICS, or if it names a nick we
// have a /whois pending for (401/402 "no such nick/server", 716 "is in +g mode
// (server-side ignore)", ...), or if it's about the WHOIS command itself (263
// "try again", 481, ...) while one is pending.
private fun isWhoisNumeric(serverId: String, line: ParsedLine): Boolean {
if (line.command in WHOIS_NUMERICS) return true
if (!line.command.all { it.isDigit() }) return false
val pending = livePendingWhois(serverId)
if (pending.isNullOrEmpty()) return false
val subject = line.params.getOrNull(1) ?: return false
return subject.lowercase() in pending || subject.equals("WHOIS", ignoreCase = true)
}
// The most recently sent /whois on this server, if it went out recently enough that a
// server or services NOTICE arriving now is plausibly a response to it.
private fun whoisAwaitingNotice(serverId: String): PendingWhois? {
val recent = livePendingWhois(serverId)?.values?.maxByOrNull { it.sentAtMillis } ?: return null
return recent.takeIf { System.currentTimeMillis() - it.sentAtMillis <= WHOIS_NOTICE_WINDOW_MS }
}
// Local-only feedback (e.g. the /reload confirmation) that never touches the // Local-only feedback (e.g. the /reload confirmation) that never touches the
// wire - just appended to the buffer the same way any other SYSTEM line is. // wire - just appended to the buffer the same way any other SYSTEM line is.
fun addSystemMessage(serverId: String, target: String, text: String) { fun addSystemMessage(serverId: String, target: String, text: String) {
@@ -282,17 +420,27 @@ object ConnectionManager {
} }
} }
// A WHOIS reply line goes to the window that typed the /whois if it's still open, // Every WHOIS reply line goes to wIRC main, under its "WHOIS <nick>" header. A reply
// otherwise (closed since, or a WHOIS nobody asked for here) to wIRC main. // 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) { private fun handleWhoisLine(serverId: String, line: ParsedLine) {
val target = line.params.getOrNull(1) ?: return val target = line.params.getOrNull(1) ?: return
val pending = pendingWhois[serverId] val pending = livePendingWhois(serverId)
val origin = pending?.get(target.lowercase()) val entry = pending?.get(target.lowercase())
if (line.command == "318") pending?.remove(target.lowercase()) ?: if (target.equals("WHOIS", ignoreCase = true)) pending?.values?.maxByOrNull { it.sentAtMillis } else null
when {
val text = "[whois] " + formatWhoisLine(line) entry != null -> showWhoisHeader(serverId, entry)
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.SYSTEM, null, text) line.command in WHOIS_NUMERICS && line.command != "318" -> {
if (origin != null && origin in openWindows) addMessage(origin, msg) else addMessage(MAIN_BUFFER_KEY, msg) 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 { private fun formatWhoisLine(line: ParsedLine): String {
@@ -372,8 +520,13 @@ object ConnectionManager {
} else if (isServiceOrServerPrefix(line.prefix)) { } else if (isServiceOrServerPrefix(line.prefix)) {
// NickServ/ChanServ replies (identify success/failure etc.) and server // NickServ/ChanServ replies (identify success/failure etc.) and server
// notices - wIRC main. The sender goes into the text because the // notices - wIRC main. The sender goes into the text because the
// NOTICE row layout doesn't show a nick column. // NOTICE row layout doesn't show a nick column. One arriving right
addMainLine(MessageKind.NOTICE, nick, "-${nick ?: "server"}- $text") // 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 { } else {
// A notice directed AT us arrives with target == our own nick, so key // A notice directed AT us arrives with target == our own nick, so key
// it by the sender, same as a private message. // it by the sender, same as a private message.
@@ -495,7 +648,7 @@ object ConnectionManager {
"PONG", "CAP" -> Unit "PONG", "CAP" -> Unit
else -> { else -> {
when { when {
line.command in WHOIS_NUMERICS -> handleWhoisLine(serverId, line) isWhoisNumeric(serverId, line) -> handleWhoisLine(serverId, line)
line.command in CHANNEL_ONLY_NUMERICS -> Unit line.command in CHANNEL_ONLY_NUMERICS -> Unit
// Every other numeric (welcome, ISUPPORT, LUSERS, MOTD, errors, ...) is a // Every other numeric (welcome, ISUPPORT, LUSERS, MOTD, errors, ...) is a
// server message; params[0] is just our own nick, so it's dropped. // server message; params[0] is just our own nick, so it's dropped.
+18 -2
View File
@@ -50,6 +50,9 @@ private fun translateOnConnectCommand(cmd: String): String {
class IrcConnection( class IrcConnection(
val config: ServerConfig, val config: ServerConfig,
private val scope: CoroutineScope, private val scope: CoroutineScope,
// Channels to rejoin on top of config.autoJoinChannels - the ones a previous connection
// to this server was in (see ConnectionManager.connect).
initialChannels: Collection<String> = emptyList(),
private val onLine: (ParsedLine) -> Unit, private val onLine: (ParsedLine) -> Unit,
private val onStatus: (String) -> Unit private val onStatus: (String) -> Unit
) { ) {
@@ -60,13 +63,17 @@ class IrcConnection(
@Volatile private var socket: Socket? = null @Volatile private var socket: Socket? = null
@Volatile private var writer: PrintWriter? = null @Volatile private var writer: PrintWriter? = null
private var readJob: Job? = null private var readJob: Job? = null
private var wantConnected = false // @Volatile: disconnect() clears it from the UI thread while the read loop checks it on
// an IO thread - it's what stops the loop from auto-reconnecting after a /quit.
@Volatile private var wantConnected = false
private var backoffMillis = 2000L private var backoffMillis = 2000L
// Every channel we're currently in, whether from config.autoJoinChannels or joined // Every channel we're currently in, whether from config.autoJoinChannels or joined
// manually mid-session - so a reconnect rejoins everything, not just the original // manually mid-session - so a reconnect rejoins everything, not just the original
// auto-join list. // auto-join list.
private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet<String>() private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet<String>(initialChannels)
fun trackedChannels(): Set<String> = joinedChannels.toSet()
// Guards runPostRegistration() against running twice for the same connection cycle // Guards runPostRegistration() against running twice for the same connection cycle
// (376 and 422 are mutually exclusive per RFC, but this is cheap insurance) - reset // (376 and 422 are mutually exclusive per RFC, but this is cheap insurance) - reset
@@ -121,6 +128,9 @@ class IrcConnection(
Socket(config.host, config.port) Socket(config.host, config.port)
} }
socket = sock socket = sock
// disconnect() can't interrupt a socket that's still being opened, so
// check here before registering on a connection nobody wants anymore.
ensureActive()
val out = PrintWriter(OutputStreamWriter(sock.getOutputStream(), Charsets.UTF_8), true) val out = PrintWriter(OutputStreamWriter(sock.getOutputStream(), Charsets.UTF_8), true)
writer = out writer = out
@@ -161,6 +171,9 @@ class IrcConnection(
handleRawLine(line) handleRawLine(line)
} }
} catch (e: Exception) { } catch (e: Exception) {
// After disconnect() the read fails because we closed the socket
// ourselves - that's not an error worth reporting.
if (!wantConnected) break
onStatus("Connection error: ${e.message}") onStatus("Connection error: ${e.message}")
ircDebug(config.id, "connection dropped: ${e.message} - tracked channels at drop time: $joinedChannels") ircDebug(config.id, "connection dropped: ${e.message} - tracked channels at drop time: $joinedChannels")
} finally { } finally {
@@ -277,6 +290,9 @@ class IrcConnection(
try { writer?.println("QUIT :leaving") } catch (_: Exception) {} try { writer?.println("QUIT :leaving") } catch (_: Exception) {}
readJob?.cancel() readJob?.cancel()
closeSocketQuietly() closeSocketQuietly()
// Ends the outbox consumer - this connection is never reused (a reconnect
// makes a new IrcConnection), so it would otherwise idle forever.
outbox.close()
} }
} }
+111
View File
@@ -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)
)
}
}
}
+474 -86
View File
@@ -2,11 +2,18 @@ package ui
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween 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.Image
import androidx.compose.foundation.LocalContextMenuRepresentation
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable 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.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState
@@ -18,8 +25,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -31,22 +38,41 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.key.* 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.ContentScale
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -62,7 +88,10 @@ import data.WindowDisplaySettings
import irc.extractImageUrls import irc.extractImageUrls
import irc.mircPalette import irc.mircPalette
import irc.parseMircToAnnotatedString import irc.parseMircToAnnotatedString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -129,6 +158,25 @@ 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) @OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable @Composable
fun WindowScope.ChannelScreen( fun WindowScope.ChannelScreen(
@@ -147,6 +195,7 @@ fun WindowScope.ChannelScreen(
onAlphaChange: (Float) -> Unit, onAlphaChange: (Float) -> Unit,
onTextColorChange: (Int) -> Unit, onTextColorChange: (Int) -> Unit,
onFontSizeChange: (Float) -> Unit, onFontSizeChange: (Float) -> Unit,
onFontChange: (String) -> Unit,
onShowUserListChange: (Boolean) -> Unit, onShowUserListChange: (Boolean) -> Unit,
onNickColorsToggle: (Boolean) -> Unit, onNickColorsToggle: (Boolean) -> Unit,
onMentionHighlightColorChange: (Int) -> Unit, onMentionHighlightColorChange: (Int) -> Unit,
@@ -162,6 +211,7 @@ fun WindowScope.ChannelScreen(
var draftBeforeHistory by remember { mutableStateOf("") } var draftBeforeHistory by remember { mutableStateOf("") }
val inputFocusRequester = remember { FocusRequester() } val inputFocusRequester = remember { FocusRequester() }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val scrollbackSelection = rememberScrollbackSelectionState()
// Reading java.awt.Toolkit's system clipboard is a blocking native (JNI/AWT) call, and // 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 // on Windows it can genuinely hang - a well-documented class of bug where the OS-level
@@ -321,7 +371,7 @@ fun WindowScope.ChannelScreen(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically 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 { Row {
FadingTitleBarButton(onClick = { onShowUserListChange(!displaySettings.showUserList) }) { FadingTitleBarButton(onClick = { onShowUserListChange(!displaySettings.showUserList) }) {
Icon(Icons.Default.Person, contentDescription = "Toggle user list", tint = textColor, modifier = Modifier.size(16.dp)) Icon(Icons.Default.Person, contentDescription = "Toggle user list", tint = textColor, modifier = Modifier.size(16.dp))
@@ -371,6 +421,7 @@ fun WindowScope.ChannelScreen(
onAlphaChange = onAlphaChange, onAlphaChange = onAlphaChange,
onTextColorChange = onTextColorChange, onTextColorChange = onTextColorChange,
onFontSizeChange = onFontSizeChange, onFontSizeChange = onFontSizeChange,
onFontChange = onFontChange,
onNickColorsToggle = onNickColorsToggle, onNickColorsToggle = onNickColorsToggle,
onMentionHighlightColorChange = onMentionHighlightColorChange, onMentionHighlightColorChange = onMentionHighlightColorChange,
onShowImagePreviewsChange = onShowImagePreviewsChange onShowImagePreviewsChange = onShowImagePreviewsChange
@@ -387,7 +438,9 @@ fun WindowScope.ChannelScreen(
displaySettings = displaySettings, displaySettings = displaySettings,
textColor = textColor, textColor = textColor,
isMention = { isMention(it) }, 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) // User list (tap and hold for OP actions)
@@ -402,7 +455,7 @@ fun WindowScope.ChannelScreen(
Text( Text(
text = (if (user.isOp) "@" else if (user.isVoice) "+" else "") + user.nick, text = (if (user.isOp) "@" else if (user.isVoice) "+" else "") + user.nick,
color = textColor, color = textColor,
fontFamily = FontFamily.Monospace, fontFamily = AppFonts.familyFor(displaySettings.fontId),
fontSize = displaySettings.fontSize.sp, fontSize = displaySettings.fontSize.sp,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -440,7 +493,7 @@ fun WindowScope.ChannelScreen(
onValueChange = { input = it }, onValueChange = { input = it },
singleLine = true, singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle( textStyle = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.Monospace, fontFamily = AppFonts.familyFor(displaySettings.fontId),
color = textColor, color = textColor,
fontSize = displaySettings.fontSize.sp fontSize = displaySettings.fontSize.sp
), ),
@@ -448,6 +501,9 @@ fun WindowScope.ChannelScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(inputFocusRequester) .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 -> .onPreviewKeyEvent { event ->
// Hardware-keyboard shortcuts: Ctrl+K color picker, Ctrl+B bold, // Hardware-keyboard shortcuts: Ctrl+K color picker, Ctrl+B bold,
// Ctrl+I italic, Ctrl+V paste (see pasteFromClipboardSafely - // Ctrl+I italic, Ctrl+V paste (see pasteFromClipboardSafely -
@@ -523,18 +579,140 @@ fun WindowScope.ChannelScreen(
} }
// Scrollback display shared by channel/PM windows (ChannelScreen) and wIRC main // Scrollback display shared by channel/PM windows (ChannelScreen) and wIRC main
// (MainScreen): the message rows, image previews, line-height snapping and the // (MainScreen): the message rows, image previews, line-height snapping, the
// stick-to-bottom auto-scroll. Draws no background of its own - callers do. // 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 @Composable
fun MessageScrollback( fun MessageScrollback(
messages: List<IrcMessage>, messages: List<IrcMessage>,
displaySettings: WindowDisplaySettings, displaySettings: WindowDisplaySettings,
textColor: Color, textColor: Color,
isMention: (String) -> Boolean, isMention: (String) -> Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
selectionState: ScrollbackSelectionState = rememberScrollbackSelectionState(),
onFocusInput: (() -> Unit)? = null
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val timeFormat = remember { SimpleDateFormat("h:mm", Locale.getDefault()) } 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 // 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 // ONLY code in this file that touches scroll position. A previous version of this had
@@ -618,9 +796,16 @@ fun MessageScrollback(
// there are no visible items yet, or the first visible item's key matches what that // 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 // captured list says should be at that index - which can only be true once layout has
// processed up through at least that item. // 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 val newestSeq = messages.lastOrNull()?.seq
LaunchedEffect(newestSeq) { val selectionActive = selectionState.isActive
if (newestSeq != null && stickToBottom && !listState.isScrollInProgress) { LaunchedEffect(newestSeq, selectionActive) {
if (newestSeq != null && !selectionActive && stickToBottom && !listState.isScrollInProgress) {
val capturedList = reversedMessages val capturedList = reversedMessages
snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull() } snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull() }
.first { item -> .first { item ->
@@ -657,11 +842,11 @@ fun MessageScrollback(
// then converts that now-stale pixel value using the NEW density, so the // then converts that now-stale pixel value using the NEW density, so the
// two silently disagreed until the window (and this remember scope) was // two silently disagreed until the window (and this remember scope) was
// recreated by closing and reopening it. // recreated by closing and reopening it.
val lineHeightPx = remember(displaySettings.fontSize, density) { val lineHeightPx = remember(displaySettings.fontSize, displaySettings.fontId, density) {
textMeasurer.measure( textMeasurer.measure(
text = "Mg", text = "Mg",
style = androidx.compose.ui.text.TextStyle( style = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.Monospace, fontFamily = AppFonts.familyFor(displaySettings.fontId),
fontSize = displaySettings.fontSize.sp fontSize = displaySettings.fontSize.sp
) )
).size.height ).size.height
@@ -691,12 +876,148 @@ fun MessageScrollback(
// offset correction through stickToBottom's own effect instead of a // offset correction through stickToBottom's own effect instead of a
// second independent one. // second independent one.
val textStyle = androidx.compose.ui.text.TextStyle(
fontFamily = AppFonts.familyFor(displaySettings.fontId),
fontSize = displaySettings.fontSize.sp
)
val selection = selectionState.selection
Column(modifier = Modifier.fillMaxSize()) { 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)
// 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( LazyColumn(
state = listState, state = listState,
reverseLayout = true, reverseLayout = true,
modifier = Modifier modifier = Modifier
.height(snappedHeight) .fillMaxSize()
.padding(horizontal = 6.dp) .padding(horizontal = 6.dp)
) { ) {
items( items(
@@ -713,89 +1034,43 @@ fun MessageScrollback(
val mentioned = isMention(msg.rawText) val mentioned = isMention(msg.rawText)
val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor
val mutedColor = textColor.copy(alpha = 0.7f) val mutedColor = textColor.copy(alpha = 0.7f)
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)
}
}
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
// Neutralizes the ambient LocalTextStyle so the plain Text() prefix/nick // Timestamp + nick are one text and the body another, both BasicText with
// pieces don't inherit MaterialTheme.typography.bodyLarge's fixed 24sp // the same explicit style - BasicText never merges the ambient LocalTextStyle,
// lineHeight from the MaterialTheme wrapper in ChannelWindow.kt - without // so neither picks up MaterialTheme's bodyLarge 24sp lineHeight (which once
// this, those Texts get a much taller line box than the ClickableText body // rendered the body visibly offset from its prefix). The body is its own text
// below (which uses its own explicit TextStyle and never merges with the // so a wrapped message stays indented under the body column.
// 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) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.let { if (mentioned) it.background(Color(displaySettings.mentionHighlightColorArgb)) else it } .let { if (mentioned) it.background(Color(displaySettings.mentionHighlightColorArgb)) else it }
) { ) {
when (msg.kind) { SelectableRowText(
MessageKind.CHAT -> { text = prefix,
Text( style = textStyle,
text = ts, selected = selection?.let { partRange(it.start, it.end, msg.seq, PART_PREFIX, prefix.length) },
color = mutedColor, partLayout = prefixLayout
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = displaySettings.fontSize.sp
) )
Text( SelectableRowText(
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(
text = bodyAnnotated, text = bodyAnnotated,
style = androidx.compose.ui.text.TextStyle( style = textStyle,
fontFamily = FontFamily.Monospace, selected = selection?.let { partRange(it.start, it.end, msg.seq, PART_BODY, bodyAnnotated.length) },
fontSize = displaySettings.fontSize.sp partLayout = bodyLayout
),
onClick = { offset ->
bodyAnnotated.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
openUrlInBrowser(annotation.item)
}
}
) )
} }
}
if (displaySettings.showImagePreviews) { if (displaySettings.showImagePreviews) {
extractImageUrls(msg.rawText).forEach { url -> extractImageUrls(msg.rawText).forEach { url ->
UrlImagePreview( UrlImagePreview(
@@ -811,11 +1086,120 @@ fun MessageScrollback(
} }
} }
} }
}
}
}
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
} }
} }
} }
// 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 // 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 // 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 // it still gets hover and clicks at alpha 0. The alpha is read inside the graphicsLayer
@@ -1022,6 +1406,7 @@ private fun WindowSettingsPanel(
onAlphaChange: (Float) -> Unit, onAlphaChange: (Float) -> Unit,
onTextColorChange: (Int) -> Unit, onTextColorChange: (Int) -> Unit,
onFontSizeChange: (Float) -> Unit, onFontSizeChange: (Float) -> Unit,
onFontChange: (String) -> Unit,
onNickColorsToggle: (Boolean) -> Unit, onNickColorsToggle: (Boolean) -> Unit,
onMentionHighlightColorChange: (Int) -> Unit, onMentionHighlightColorChange: (Int) -> Unit,
onShowImagePreviewsChange: (Boolean) -> Unit onShowImagePreviewsChange: (Boolean) -> Unit
@@ -1059,6 +1444,9 @@ private fun WindowSettingsPanel(
colors = sliderColors colors = sliderColors
) )
Spacer(Modifier.height(4.dp)) 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) Text("Text color:", color = textColor, style = MaterialTheme.typography.bodySmall)
FlowSwatchGrid( FlowSwatchGrid(
selected = displaySettings.textColorMircIndex, selected = displaySettings.textColorMircIndex,
+39 -13
View File
@@ -20,9 +20,20 @@ import data.WindowPrefsRepository
import data.isChannelName import data.isChannelName
import data.storageId import data.storageId
import irc.ConnectionManager import irc.ConnectionManager
import irc.IrcConnection
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val NOT_CONNECTED_TEXT = "Not connected - use Connect in wIRC main to reconnect"
// Runs [action] on this server's connection, or shows the not-connected line in this
// window when there isn't one (e.g. the user-list kick/ban/op menu after a /quit).
private fun withConnection(serverId: String, windowName: String, action: (IrcConnection) -> Unit) {
val conn = ConnectionManager.connectionFor(serverId)
if (conn != null) action(conn)
else ConnectionManager.addSystemMessage(serverId, windowName, NOT_CONNECTED_TEXT)
}
/** /**
* One of these is composed per open buffer key (see Main.kt) - each is a real, independent * One of these is composed per open buffer key (see Main.kt) - each is a real, independent
* top-level OS window, same intent as ChannelWindowActivity.kt's per-channel Activity on * top-level OS window, same intent as ChannelWindowActivity.kt's per-channel Activity on
@@ -36,7 +47,7 @@ fun ChannelWindow(
colorScheme: ColorScheme, colorScheme: ColorScheme,
onOpenBuffer: (BufferKey) -> Unit, onOpenBuffer: (BufferKey) -> Unit,
onCloseSelf: () -> Unit, onCloseSelf: () -> Unit,
onQuitApp: () -> Unit onDisconnectServer: () -> Unit
) { ) {
val serverId = bufferKey.serverId val serverId = bufferKey.serverId
val channelName = bufferKey.name val channelName = bufferKey.name
@@ -87,7 +98,19 @@ fun ChannelWindow(
displaySettings = displaySettings, displaySettings = displaySettings,
colorScheme = colorScheme, colorScheme = colorScheme,
ownNick = ownNick, ownNick = ownNick,
onSend = { text -> onSend = onSend@{ text ->
// Everything except /reload, aliases and /part (which only closes
// the window) needs the server - while disconnected it gets this
// line in this window instead of going nowhere.
val needsServer = if (text.startsWith("/")) {
val command = text.substring(1).substringBefore(' ').uppercase()
command != "RELOAD" && command !in setOf("PART", "EXIT", "CLOSE") &&
!AliasRepository.hasAlias(command)
} else true
if (needsServer && !ConnectionManager.isConnected(serverId)) {
ConnectionManager.addSystemMessage(serverId, channelName, NOT_CONNECTED_TEXT)
return@onSend
}
if (text.startsWith("/")) { if (text.startsWith("/")) {
val withoutSlash = text.substring(1) val withoutSlash = text.substring(1)
val spaceIdx = withoutSlash.indexOf(' ') val spaceIdx = withoutSlash.indexOf(' ')
@@ -112,24 +135,24 @@ fun ChannelWindow(
"NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args") "NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args")
"PART", "EXIT", "CLOSE" -> { "PART", "EXIT", "CLOSE" -> {
val target = args.ifBlank { channelName } val target = args.ifBlank { channelName }
ConnectionManager.connectionFor(serverId)?.partChannel(target, "leaving") ConnectionManager.partChannel(serverId, target, "leaving")
if (args.isBlank() || target == channelName) onCloseSelf() if (args.isBlank() || target == channelName) onCloseSelf()
} }
"QUIT" -> { "QUIT" -> {
if (rawArgs.isBlank()) { if (rawArgs.isBlank()) {
ConnectionManager.disconnectAll() // Disconnects this window's server only - every
onQuitApp() // window stays open; see ConnectionManager.disconnect.
onDisconnectServer()
} else { } else {
ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs) ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs)
} }
} }
"JOIN" -> { "JOIN" -> {
ConnectionManager.sendRaw(serverId, "JOIN $args") ConnectionManager.joinChannels(serverId, args)
onOpenBuffer(BufferKey(serverId, args)) onOpenBuffer(BufferKey(serverId, args))
} }
// Reply lines come back to this window, or to wIRC main if // All whois output goes to wIRC main, not this window.
// this window is closed before they arrive. "WHOIS" -> ConnectionManager.whois(serverId, args)
"WHOIS" -> ConnectionManager.whois(serverId, args, origin = bufferKey)
"MSG" -> { "MSG" -> {
val msgSpaceIdx = args.indexOf(' ') val msgSpaceIdx = args.indexOf(' ')
if (msgSpaceIdx >= 0) { if (msgSpaceIdx >= 0) {
@@ -145,10 +168,10 @@ fun ChannelWindow(
ConnectionManager.sendChannelMessage(serverId, channelName, text) ConnectionManager.sendChannelMessage(serverId, channelName, text)
} }
}, },
onKick = { nick -> ConnectionManager.connectionFor(serverId)?.kick(channelName, nick) }, onKick = { nick -> withConnection(serverId, channelName) { it.kick(channelName, nick) } },
onBan = { nick -> ConnectionManager.connectionFor(serverId)?.ban(channelName, "$nick!*@*") }, onBan = { nick -> withConnection(serverId, channelName) { it.ban(channelName, "$nick!*@*") } },
onOp = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "+o", nick) }, onOp = { nick -> withConnection(serverId, channelName) { it.setMode(channelName, "+o", nick) } },
onDeop = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "-o", nick) }, onDeop = { nick -> withConnection(serverId, channelName) { it.setMode(channelName, "-o", nick) } },
onAlphaChange = { alpha -> onAlphaChange = { alpha ->
scope.launch(Dispatchers.IO) { prefsRepo.setAlpha(bufferKey.storageId(), alpha) } scope.launch(Dispatchers.IO) { prefsRepo.setAlpha(bufferKey.storageId(), alpha) }
}, },
@@ -158,6 +181,9 @@ fun ChannelWindow(
onFontSizeChange = { size -> onFontSizeChange = { size ->
scope.launch(Dispatchers.IO) { prefsRepo.setFontSize(bufferKey.storageId(), size) } scope.launch(Dispatchers.IO) { prefsRepo.setFontSize(bufferKey.storageId(), size) }
}, },
onFontChange = { id ->
scope.launch(Dispatchers.IO) { prefsRepo.setFontId(bufferKey.storageId(), id) }
},
onShowUserListChange = { show -> onShowUserListChange = { show ->
scope.launch(Dispatchers.IO) { prefsRepo.setShowUserList(bufferKey.storageId(), show) } scope.launch(Dispatchers.IO) { prefsRepo.setShowUserList(bufferKey.storageId(), show) }
}, },
+40 -8
View File
@@ -43,12 +43,16 @@ fun MainScreen(
displaySettings: WindowDisplaySettings, displaySettings: WindowDisplaySettings,
onOpenChannel: (serverId: String, channelName: String) -> Unit, onOpenChannel: (serverId: String, channelName: String) -> Unit,
onExitApp: () -> 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 servers by remember { mutableStateOf(repo.loadAll()) }
var showAddDialog by remember { mutableStateOf(false) } var showAddDialog by remember { mutableStateOf(false) }
var editingServer by remember { mutableStateOf<ServerConfig?>(null) } var editingServer by remember { mutableStateOf<ServerConfig?>(null) }
var connectedIds by remember { mutableStateOf(setOf<String>()) } // From ConnectionManager rather than local state, so a /quit typed in a channel window
// flips that server's card back to Disconnected with its Connect button.
val connectedIds by ConnectionManager.connectedIds.collectAsState()
var channelInputs by remember { mutableStateOf(mapOf<String, String>()) } var channelInputs by remember { mutableStateOf(mapOf<String, String>()) }
var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) } var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -85,9 +89,14 @@ fun MainScreen(
Text("+ Add Server") Text("+ Add Server")
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
// Same end result as typing "/quit" alone in a channel window - disconnects // Font for the server messages box (channel windows pick theirs in their own
// every server and terminates the process - just reachable without needing // settings).
// a channel window open first. TextButton(onClick = { showFontDialog = true }) {
Text("Font")
}
Spacer(Modifier.width(8.dp))
// Disconnects every server and terminates the process. (A bare "/quit" in a
// channel window only disconnects that window's server.)
TextButton(onClick = onExitApp) { TextButton(onClick = onExitApp) {
Text("Exit wIRC") Text("Exit wIRC")
} }
@@ -108,8 +117,9 @@ fun MainScreen(
openedChannels = openedChannels[server.id] ?: emptyList(), openedChannels = openedChannels[server.id] ?: emptyList(),
onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) }, onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) },
onConnect = { onConnect = {
// 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) ConnectionManager.connect(server)
connectedIds = connectedIds + server.id
if (server.autoJoinChannels.isNotEmpty()) { if (server.autoJoinChannels.isNotEmpty()) {
// Auto-join sends JOIN correctly on connect (see IrcConnection), // Auto-join sends JOIN correctly on connect (see IrcConnection),
// but nothing else opens a window for it like the manual // but nothing else opens a window for it like the manual
@@ -168,8 +178,9 @@ fun MainScreen(
} }
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
// MOTD, numerics, errors, services/server notices and WHOIS replies whose window // MOTD, numerics, errors, services/server notices and all /whois output - see
// is gone - see ConnectionManager. Same rendering and auto-scroll as a channel window. // ConnectionManager. Same rendering, auto-scroll and text selection as a channel
// window.
MessageScrollback( MessageScrollback(
messages = mainMessages, messages = mainMessages,
displaySettings = displaySettings, displaySettings = displaySettings,
@@ -184,6 +195,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 = textColor, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(4.dp))
FontPickerList(displaySettings.fontId, textColor, onFontChange)
}
}
}
}
}
if (showAddDialog) { if (showAddDialog) {
ServerEditDialog( ServerEditDialog(
existing = editingServer, existing = editingServer,
@@ -242,6 +272,8 @@ private fun ServerCard(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
if (!isConnected) { if (!isConnected) {
Text("Disconnected", style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(4.dp))
Button(onClick = onConnect) { Text("Connect") } Button(onClick = onConnect) { Text("Connect") }
} else { } else {
Text("Connected", style = MaterialTheme.typography.bodySmall) Text("Connected", style = MaterialTheme.typography.bodySmall)
Binary file not shown.
Binary file not shown.
+93
View File
@@ -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.