5 Commits
Author SHA1 Message Date
weishauptandClaude Opus 5.5 4ac7b75962 Keep image previews and wrapped messages on whole lines so the top line isn't cut off, bump to 0.1.13
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:19:31 -04:00
weishauptandClaude Opus 5.5 5bcd1a7bcd Snap windows to the desktop grid and to whole lines, font size picker (10 to 18), white text in settings dialogs, bump to 0.1.12
- After a move, windows snap to the Windows desktop icon grid; after a resize,
  the window height is trimmed so the scrollback ends on a whole line.
- The font size slider is replaced by square 10 to 18 buttons. Sizes saved by
  the old slider round to the nearest picker size.
- Settings and font dialogs always use white text, so a dark window text color
  no longer makes their labels invisible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:49:55 -04:00
weishauptandClaude Opus 5.5 026e349db8 No mention highlight on join/part/quit lines, bump to 0.1.11
Only chat, action and notice lines get the mention tint now. Join, part,
quit, nick and mode lines about you always contain your nick, so every one
of your own joins used to be highlighted as a mention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:16:02 -04:00
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
19 changed files with 896 additions and 60 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ plugins {
} }
group = "com.tpuahsiew.wirc" group = "com.tpuahsiew.wirc"
version = "0.1.8" version = "0.1.13"
// 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.8" packageVersion = "0.1.13"
windows { windows {
menuGroup = "wIRC" menuGroup = "wIRC"
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a" upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
+16 -6
View File
@@ -51,6 +51,8 @@ fun main() = application {
// Loaded once here (not per-window) so the alias map is truly global - every // Loaded once here (not per-window) so the alias map is truly global - every
// ChannelWindow's command router reads from this same AliasRepository singleton. // ChannelWindow's command router reads from this same AliasRepository singleton.
AliasRepository.init() AliasRepository.init()
// Ask Windows for the desktop icon grid now, so the first window drag can already snap to it
ui.DesktopGrid.refreshAsync()
val repo = remember { ServerRepository(appRoot) } val repo = remember { ServerRepository(appRoot) }
val prefsRepo = remember { WindowPrefsRepository(appRoot) } val prefsRepo = remember { WindowPrefsRepository(appRoot) }
@@ -58,9 +60,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) }
// Full shutdown: same steps /quit (bare, no args) already runs from a channel window - // Full shutdown for the "Exit wIRC" button: disconnect every server (which also writes
// disconnect every server, then end the process. Shared here so the "Exit wIRC" button // all scrollback to disk), then end the process. A bare /quit in a channel window only
// and every channel window's /quit reuse the exact same logic instead of duplicating it. // disconnects that window's server - see ConnectionManager.disconnect.
val shutdownApp: () -> Unit = { val shutdownApp: () -> Unit = {
ConnectionManager.disconnectAll() ConnectionManager.disconnectAll()
exitApplication() exitApplication()
@@ -85,6 +87,9 @@ fun main() = application {
// ("wirc-main__main"), same as every channel and PM window - see WindowPlacement.kt. // ("wirc-main__main"), same as every channel and PM window - see WindowPlacement.kt.
val mainWindowState = rememberPersistentWindowState(prefsRepo, MAIN_BUFFER_KEY.storageId(), DpSize(800.dp, 700.dp)) val mainWindowState = rememberPersistentWindowState(prefsRepo, MAIN_BUFFER_KEY.storageId(), DpSize(800.dp, 700.dp))
val mainDisplaySettings by prefsRepo.settingsFor(MAIN_BUFFER_KEY.storageId()).collectAsState() val mainDisplaySettings by prefsRepo.settingsFor(MAIN_BUFFER_KEY.storageId()).collectAsState()
// Snap to the desktop grid after a move, and to whole lines after a resize
val mainLineGap = ui.rememberLineGap()
ui.WindowSnapping(mainWindowState, mainLineGap, osFrame = true)
// There's no persistent tray/notification surface on this build, so closing the main // There's no persistent tray/notification surface on this build, so closing the main
// server-list window quits the whole app (and every channel window with it) - the // server-list window quits the whole app (and every channel window with it) - the
@@ -98,6 +103,7 @@ fun main() = application {
icon = painterResource("icon.png"), icon = painterResource("icon.png"),
state = mainWindowState state = mainWindowState
) { ) {
androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) {
MaterialTheme(colorScheme = neutralDarkScheme) { MaterialTheme(colorScheme = neutralDarkScheme) {
Surface { Surface {
MainScreen( MainScreen(
@@ -108,11 +114,13 @@ 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) }
) )
} }
} }
} }
}
openBuffers.forEach { bufferKey -> openBuffers.forEach { bufferKey ->
key(bufferKey) { key(bufferKey) {
@@ -122,8 +130,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
+15 -2
View File
@@ -10,10 +10,18 @@ import java.nio.file.Files
import java.nio.file.StandardCopyOption import java.nio.file.StandardCopyOption
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
// Font sizes offered in the settings dialog (whole sizes only, same range as QuickClaude)
const val MIN_FONT_SIZE = 10
const val MAX_FONT_SIZE = 18
private fun pickerFontSize(size: Float): Float =
Math.round(size).coerceIn(MIN_FONT_SIZE, MAX_FONT_SIZE).toFloat()
data class WindowDisplaySettings( data class WindowDisplaySettings(
val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque
val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white
val fontSize: Float = 14f, val fontSize: Float = 14f,
val fontId: String = "monospace", // see ui/AppFonts.kt; unknown/missing ids fall back to monospace
val showUserList: Boolean = true, val showUserList: Boolean = true,
val mentionHighlightColorArgb: Int = 0x40FFA500.toInt(), // translucent orange val mentionHighlightColorArgb: Int = 0x40FFA500.toInt(), // translucent orange
val nickColorsEnabled: Boolean = true, val nickColorsEnabled: Boolean = true,
@@ -62,7 +70,9 @@ class WindowPrefsRepository(rootDir: File) {
fun setTextColorMircIndex(bufferKeyId: String, index: Int) = update(bufferKeyId) { it.copy(textColorMircIndex = index) } fun setTextColorMircIndex(bufferKeyId: String, index: Int) = update(bufferKeyId) { it.copy(textColorMircIndex = index) }
fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = fontSize) } fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = pickerFontSize(fontSize)) }
fun setFontId(bufferKeyId: String, fontId: String) = update(bufferKeyId) { it.copy(fontId = fontId) }
fun setShowUserList(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showUserList = show) } fun setShowUserList(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showUserList = show) }
@@ -106,7 +116,9 @@ class WindowPrefsRepository(rootDir: File) {
stored[key] = WindowDisplaySettings( stored[key] = WindowDisplaySettings(
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(), backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
textColorMircIndex = o.optInt("textColorMircIndex", 0), textColorMircIndex = o.optInt("textColorMircIndex", 0),
fontSize = o.optDouble("fontSize", 14.0).toFloat(), // Sizes saved by the old slider (any value from 10 to 24) round to the nearest picker size
fontSize = pickerFontSize(o.optDouble("fontSize", 14.0).toFloat()),
fontId = o.optString("fontId", "monospace"),
showUserList = o.optBoolean("showUserList", true), showUserList = o.optBoolean("showUserList", true),
mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(), mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(),
nickColorsEnabled = o.optBoolean("nickColorsEnabled", true), nickColorsEnabled = o.optBoolean("nickColorsEnabled", true),
@@ -138,6 +150,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)
+83 -4
View File
@@ -73,6 +73,17 @@ object ConnectionManager {
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap()) private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
// Servers that have a live connection (or one that's connecting/auto-reconnecting after
// a network drop). A server leaves this set only through disconnect()/disconnectAll(),
// and it's what wIRC main's server cards show as Connected / Disconnected.
private val _connectedIds = MutableStateFlow<Set<String>>(emptySet())
val connectedIds: StateFlow<Set<String>> = _connectedIds
// serverId -> channels we were in when /quit disconnected that server, so the next
// connect() rejoins them (and the windows still open for them pick right back up)
// instead of only the configured auto-join list.
private val channelsToRejoin = ConcurrentHashMap<String, MutableSet<String>>()
// A /whois we sent and haven't seen the end of yet. headerShown tracks whether its // 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. // "WHOIS <nick>" header line is already in wIRC main.
private class PendingWhois(val nick: String, val sentAtMillis: Long, var headerShown: Boolean) private class PendingWhois(val nick: String, val sentAtMillis: Long, var headerShown: Boolean)
@@ -142,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") }
@@ -178,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") }
+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)
)
}
}
}
+71 -17
View File
@@ -61,6 +61,7 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput 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.LayoutCoordinates
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned 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.AnnotatedString
@@ -83,6 +84,8 @@ import androidx.compose.ui.window.rememberDialogState
import data.ChannelBuffer import data.ChannelBuffer
import data.ChannelUser import data.ChannelUser
import data.IrcMessage import data.IrcMessage
import data.MAX_FONT_SIZE
import data.MIN_FONT_SIZE
import data.MessageKind import data.MessageKind
import data.WindowDisplaySettings import data.WindowDisplaySettings
import irc.extractImageUrls import irc.extractImageUrls
@@ -102,6 +105,7 @@ import java.net.URL
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.math.floor import kotlin.math.floor
import kotlin.math.roundToInt
// Title-bar button fade (nicklist / settings / minimize / close). The buttons sit at // Title-bar button fade (nicklist / settings / minimize / close). The buttons sit at
// TITLE_BAR_BUTTON_REST_ALPHA until hovered, then animate to TITLE_BAR_BUTTON_HOVER_ALPHA // TITLE_BAR_BUTTON_REST_ALPHA until hovered, then animate to TITLE_BAR_BUTTON_HOVER_ALPHA
@@ -195,6 +199,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,
@@ -370,7 +375,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))
@@ -416,10 +421,13 @@ fun WindowScope.ChannelScreen(
Surface { Surface {
WindowSettingsPanel( WindowSettingsPanel(
displaySettings = displaySettings, displaySettings = displaySettings,
textColor = textColor, // Always white: the dialog is black, so the window's own text
// color (black, say) would make every label invisible here
textColor = Color.White,
onAlphaChange = onAlphaChange, onAlphaChange = onAlphaChange,
onTextColorChange = onTextColorChange, onTextColorChange = onTextColorChange,
onFontSizeChange = onFontSizeChange, onFontSizeChange = onFontSizeChange,
onFontChange = onFontChange,
onNickColorsToggle = onNickColorsToggle, onNickColorsToggle = onNickColorsToggle,
onMentionHighlightColorChange = onMentionHighlightColorChange, onMentionHighlightColorChange = onMentionHighlightColorChange,
onShowImagePreviewsChange = onShowImagePreviewsChange onShowImagePreviewsChange = onShowImagePreviewsChange
@@ -453,7 +461,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()
@@ -491,7 +499,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
), ),
@@ -840,11 +848,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
@@ -857,10 +865,14 @@ fun MessageScrollback(
// real cause was a race between the scroll effect and the LazyColumn's // real cause was a race between the scroll effect and the LazyColumn's
// own layout catching up to new items - see the scroll effects below. // own layout catching up to new items - see the scroll effects below.
val snappedHeight = if (lineHeightDp > 0.dp) { val snappedHeight = if (lineHeightDp > 0.dp) {
lineHeightDp * floor(maxHeight / lineHeightDp).toInt() minOf(maxHeight, lineHeightDp * wholeLines(maxHeight, lineHeightDp))
} else { } else {
maxHeight maxHeight
} }
// Tells the window how much space is left under the last whole line, so a resize can
// trim the window itself to fit (see WindowSnapping.kt)
val lineGap = LocalLineGap.current
SideEffect { lineGap?.gap = maxHeight - snappedHeight }
// NOTE: this used to also re-snap the scroll *offset* to the first // NOTE: this used to also re-snap the scroll *offset* to the first
// visible item on every resize/font-size change, to fix a partial line // visible item on every resize/font-size change, to fix a partial line
@@ -875,7 +887,7 @@ fun MessageScrollback(
// second independent one. // second independent one.
val textStyle = androidx.compose.ui.text.TextStyle( val textStyle = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.Monospace, fontFamily = AppFonts.familyFor(displaySettings.fontId),
fontSize = displaySettings.fontSize.sp fontSize = displaySettings.fontSize.sp
) )
val selection = selectionState.selection val selection = selectionState.selection
@@ -1029,7 +1041,10 @@ fun MessageScrollback(
) { msg -> ) { msg ->
val ts = timeFormat.format(Date(msg.timestampMillis)) val ts = timeFormat.format(Date(msg.timestampMillis))
val bodyAnnotated = parseMircToAnnotatedString(msg.rawText, textColor) val bodyAnnotated = parseMircToAnnotatedString(msg.rawText, textColor)
val mentioned = isMention(msg.rawText) // Only real messages get the mention tint. Join/part/quit/nick/mode lines
// always contain your nick when they're about you ("you joined #chan"),
// which used to light up every one of your own joins.
val mentioned = msg.kind in MENTION_KINDS && isMention(msg.rawText)
val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor val 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 prefix = rowPrefix(msg, ts, mutedColor, nickColor)
@@ -1045,7 +1060,10 @@ fun MessageScrollback(
} }
} }
Column(modifier = Modifier.fillMaxWidth()) { // Every row is kept a whole number of lines tall (nearest whole line, to absorb
// the pixel or two a wrapped message can be off by), so the line snapping above
// stays lined up all the way to the top of the window instead of drifting.
Column(modifier = Modifier.fillMaxWidth().snapHeightToLines(lineHeightPx, roundUp = false)) {
// Timestamp + nick are one text and the body another, both BasicText with // Timestamp + nick are one text and the body another, both BasicText with
// the same explicit style - BasicText never merges the ambient LocalTextStyle, // the same explicit style - BasicText never merges the ambient LocalTextStyle,
// so neither picks up MaterialTheme's bodyLarge 24sp lineHeight (which once // so neither picks up MaterialTheme's bodyLarge 24sp lineHeight (which once
@@ -1071,6 +1089,10 @@ fun MessageScrollback(
} }
if (displaySettings.showImagePreviews) { if (displaySettings.showImagePreviews) {
extractImageUrls(msg.rawText).forEach { url -> extractImageUrls(msg.rawText).forEach { url ->
// A thumbnail can be any height, so its slot is rounded UP to whole lines
// (never cropping the picture); otherwise every line above it in the
// window would sit a fraction of a line off and show cut in half at the top.
Box(modifier = Modifier.snapHeightToLines(lineHeightPx, roundUp = true)) {
UrlImagePreview( UrlImagePreview(
url = url, url = url,
modifier = Modifier modifier = Modifier
@@ -1087,11 +1109,26 @@ fun MessageScrollback(
} }
} }
} }
}
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
} }
} }
} }
// Reports this element's height as a whole number of text lines ([linePx] each): rounded up
// for image slots (the picture is never cut), to the nearest line for text rows (a wrapped
// message can measure a pixel or two off an exact multiple). Content is drawn from the top.
private fun Modifier.snapHeightToLines(linePx: Int, roundUp: Boolean): Modifier = layout { measurable, constraints ->
val placeable = measurable.measure(constraints.copy(minHeight = 0))
val h = placeable.height
val snapped = when {
linePx <= 0 || h == 0 -> h
roundUp -> ((h + linePx - 1) / linePx) * linePx
else -> (((h + linePx / 2) / linePx).coerceAtLeast(1)) * linePx
}.coerceIn(constraints.minHeight, constraints.maxHeight.coerceAtLeast(constraints.minHeight))
layout(placeable.width, snapped) { placeable.place(0, 0) }
}
// Which of a row's two texts a TextPos is in: the timestamp/nick prefix, or the body. // 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_PREFIX = 0
private const val PART_BODY = 1 private const val PART_BODY = 1
@@ -1397,6 +1434,8 @@ private fun MentionColorPickerDialog(currentArgb: Int, onDismiss: () -> Unit, on
) )
} }
private val MENTION_KINDS = setOf(MessageKind.CHAT, MessageKind.ACTION, MessageKind.NOTICE)
@Composable @Composable
private fun WindowSettingsPanel( private fun WindowSettingsPanel(
displaySettings: WindowDisplaySettings, displaySettings: WindowDisplaySettings,
@@ -1404,6 +1443,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
@@ -1433,13 +1473,27 @@ private fun WindowSettingsPanel(
colors = sliderColors colors = sliderColors
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text("Font size", color = textColor, style = MaterialTheme.typography.bodySmall) Text("Font size:", color = textColor, style = MaterialTheme.typography.bodySmall)
Slider( // Fixed sizes 10 to 18 (same as QuickClaude's Font size menu), square boxes in a row
value = displaySettings.fontSize, val currentSize = displaySettings.fontSize.roundToInt()
onValueChange = onFontSizeChange, Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
valueRange = 10f..24f, for (size in MIN_FONT_SIZE..MAX_FONT_SIZE) {
colors = sliderColors val selected = size == currentSize
) Box(
modifier = Modifier
.size(28.dp)
.background(if (selected) Color.White.copy(alpha = 0.25f) else Color.Transparent)
.border(1.dp, if (selected) Color.White else Color.White.copy(alpha = 0.35f))
.clickableNoRipple { onFontSizeChange(size.toFloat()) },
contentAlignment = Alignment.Center
) {
Text(size.toString(), color = textColor, fontSize = 12.sp)
}
}
}
Spacer(Modifier.height(4.dp))
Text("Font:", color = textColor, style = MaterialTheme.typography.bodySmall)
FontPickerList(displaySettings.fontId, textColor, onFontChange)
Spacer(Modifier.height(4.dp)) 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(
+43 -10
View File
@@ -3,6 +3,7 @@ package ui
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@@ -20,9 +21,20 @@ import data.WindowPrefsRepository
import data.isChannelName import data.isChannelName
import data.storageId import data.storageId
import irc.ConnectionManager import irc.ConnectionManager
import irc.IrcConnection
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val NOT_CONNECTED_TEXT = "Not connected - use Connect in wIRC main to reconnect"
// Runs [action] on this server's connection, or shows the not-connected line in this
// window when there isn't one (e.g. the user-list kick/ban/op menu after a /quit).
private fun withConnection(serverId: String, windowName: String, action: (IrcConnection) -> Unit) {
val conn = ConnectionManager.connectionFor(serverId)
if (conn != null) action(conn)
else ConnectionManager.addSystemMessage(serverId, windowName, NOT_CONNECTED_TEXT)
}
/** /**
* One of these is composed per open buffer key (see Main.kt) - each is a real, independent * One of these is composed per open buffer key (see Main.kt) - each is a real, independent
* top-level OS window, same intent as ChannelWindowActivity.kt's per-channel Activity on * top-level OS window, same intent as ChannelWindowActivity.kt's per-channel Activity on
@@ -36,7 +48,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
@@ -63,6 +75,9 @@ fun ChannelWindow(
// Restored from window_prefs.json (or cascaded if there's no usable saved position) // Restored from window_prefs.json (or cascaded if there's no usable saved position)
// and saved back on move/resize/close - see WindowPlacement.kt. // and saved back on move/resize/close - see WindowPlacement.kt.
val windowState = rememberPersistentWindowState(prefsRepo, bufferKey.storageId(), DpSize(800.dp, 600.dp)) val windowState = rememberPersistentWindowState(prefsRepo, bufferKey.storageId(), DpSize(800.dp, 600.dp))
// Snap to the desktop grid after a move, and to whole lines after a resize
val lineGap = rememberLineGap()
WindowSnapping(windowState, lineGap)
// undecorated + transparent trade the native OS title bar for real per-pixel window // undecorated + transparent trade the native OS title bar for real per-pixel window
// transparency (Windows only honors transparency on undecorated windows) - ChannelScreen // transparency (Windows only honors transparency on undecorated windows) - ChannelScreen
@@ -78,6 +93,7 @@ fun ChannelWindow(
transparent = true, transparent = true,
resizable = true resizable = true
) { ) {
CompositionLocalProvider(LocalLineGap provides lineGap) {
MaterialTheme(colorScheme = colorScheme) { MaterialTheme(colorScheme = colorScheme) {
ChannelScreen( ChannelScreen(
windowState = windowState, windowState = windowState,
@@ -87,7 +103,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,19 +140,20 @@ fun ChannelWindow(
"NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args") "NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args")
"PART", "EXIT", "CLOSE" -> { "PART", "EXIT", "CLOSE" -> {
val target = args.ifBlank { channelName } val target = args.ifBlank { channelName }
ConnectionManager.connectionFor(serverId)?.partChannel(target, "leaving") ConnectionManager.partChannel(serverId, target, "leaving")
if (args.isBlank() || target == channelName) onCloseSelf() if (args.isBlank() || target == channelName) onCloseSelf()
} }
"QUIT" -> { "QUIT" -> {
if (rawArgs.isBlank()) { if (rawArgs.isBlank()) {
ConnectionManager.disconnectAll() // Disconnects this window's server only - every
onQuitApp() // window stays open; see ConnectionManager.disconnect.
onDisconnectServer()
} else { } else {
ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs) ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs)
} }
} }
"JOIN" -> { "JOIN" -> {
ConnectionManager.sendRaw(serverId, "JOIN $args") ConnectionManager.joinChannels(serverId, args)
onOpenBuffer(BufferKey(serverId, args)) onOpenBuffer(BufferKey(serverId, args))
} }
// All whois output goes to wIRC main, not this window. // All whois output goes to wIRC main, not this window.
@@ -144,10 +173,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) }
}, },
@@ -157,6 +186,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) }
}, },
@@ -173,3 +205,4 @@ fun ChannelWindow(
} }
} }
} }
}
+37 -6
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
@@ -185,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 = Color.White, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(4.dp))
FontPickerList(displaySettings.fontId, Color.White, onFontChange)
}
}
}
}
}
if (showAddDialog) { if (showAddDialog) {
ServerEditDialog( ServerEditDialog(
existing = editingServer, existing = editingServer,
@@ -243,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)
+204
View File
@@ -0,0 +1,204 @@
package ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.WindowState
import java.awt.AWTEvent
import java.awt.GraphicsEnvironment
import java.awt.Rectangle
import java.awt.Toolkit
import java.awt.event.AWTEventListener
import java.awt.event.MouseEvent
import java.util.Base64
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlin.math.floor
import kotlin.math.roundToInt
/**
* Space left over under the last whole line of a window's scrollback, reported by
* MessageScrollback (which already sizes the scrollback itself to whole lines). When a
* resize ends, the window is trimmed by this much so the window itself fits whole lines too.
*/
class LineGap {
@Volatile var gap: Dp = 0.dp
}
val LocalLineGap = staticCompositionLocalOf<LineGap?> { null }
@Composable
fun rememberLineGap(): LineGap = remember { LineGap() }
/**
* After the mouse is let go at the end of a drag or resize:
* - a move snaps the window's top-left corner to the desktop icon grid
* - a resize trims the window to whole lines of scrollback text (keeping whichever edge
* didn't move in place)
* Channel windows are undecorated, so their moves and resizes happen inside the app (the
* title-bar drag area and Compose's own edge resizer) and the mouse release reaches wIRC.
* wIRC main has a normal Windows frame, whose drags Windows handles itself without telling
* the app when the button comes up, so for it ([osFrame] = true) the snap happens once the
* window has sat still for a moment instead.
*/
@Composable
fun WindowSnapping(state: WindowState, lineGap: LineGap, osFrame: Boolean = false) {
if (osFrame) {
LaunchedEffect(state, lineGap) {
var settledPos: WindowPosition = state.position
var settledSize: DpSize = state.size
snapshotFlow { state.position to state.size }.collectLatest {
delay(OS_FRAME_SETTLE_MS)
afterMoveOrResize(state, lineGap, settledPos, settledSize)
settledPos = state.position
settledSize = state.size
}
}
return
}
DisposableEffect(state, lineGap) {
var startPos: WindowPosition? = null
var startSize: DpSize? = null
val listener = AWTEventListener { event ->
val e = event as? MouseEvent ?: return@AWTEventListener
if (e.button != MouseEvent.BUTTON1) return@AWTEventListener
when (e.id) {
MouseEvent.MOUSE_PRESSED -> {
startPos = state.position
startSize = state.size
}
MouseEvent.MOUSE_RELEASED -> {
val p0 = startPos
val s0 = startSize
startPos = null
startSize = null
if (p0 != null && s0 != null) afterMoveOrResize(state, lineGap, p0, s0)
}
}
}
Toolkit.getDefaultToolkit().addAWTEventListener(listener, AWTEvent.MOUSE_EVENT_MASK)
onDispose { Toolkit.getDefaultToolkit().removeAWTEventListener(listener) }
}
}
// How long wIRC main has to sit still after a move/resize before it snaps
private const val OS_FRAME_SETTLE_MS = 400L
private fun afterMoveOrResize(state: WindowState, lineGap: LineGap, p0: WindowPosition, s0: DpSize) {
if (state.isMinimized || state.placement != WindowPlacement.Floating) return
val pos = state.position as? WindowPosition.Absolute ?: return
val size = state.size
if (!size.isSpecified || !s0.isSpecified) return
val start = p0 as? WindowPosition.Absolute
val resized = (size.width - s0.width).value.let { it > 0.5f || it < -0.5f } ||
(size.height - s0.height).value.let { it > 0.5f || it < -0.5f }
if (resized) {
val gap = lineGap.gap
if (gap > 0.5.dp && size.height - gap >= 120.dp) {
// Dragged the top edge (top moved, bottom stayed): keep the bottom where it is
val fromTop = start != null && (pos.y - start.y).value.let { it > 0.5f || it < -0.5f } &&
((pos.y + size.height) - (start.y + s0.height)).value.let { it < 0.5f && it > -0.5f }
state.size = DpSize(size.width, size.height - gap)
if (fromTop) state.position = WindowPosition.Absolute(pos.x, pos.y + gap)
}
return
}
if (start == null || (start.x == pos.x && start.y == pos.y)) return
val (sx, sy) = DesktopGrid.spacing() ?: return
val area = workAreaAt(pos.x.value, pos.y.value)
val x = area.x + ((pos.x.value - area.x) / sx).roundToInt() * sx
val y = area.y + ((pos.y.value - area.y) / sy).roundToInt() * sy
if (x != pos.x.value || y != pos.y.value) state.position = WindowPosition.Absolute(x.dp, y.dp)
}
// The usable area (taskbar excluded) of the screen the window's corner is on, in the same
// logical units as WindowState positions.
private fun workAreaAt(x: Float, y: Float): Rectangle = try {
val ge = GraphicsEnvironment.getLocalGraphicsEnvironment()
val config = ge.screenDevices.map { it.defaultConfiguration }
.firstOrNull { it.bounds.contains(x.toInt(), y.toInt()) } ?: ge.defaultScreenDevice.defaultConfiguration
val b = config.bounds
val i = Toolkit.getDefaultToolkit().getScreenInsets(config)
Rectangle(b.x + i.left, b.y + i.top, b.width - i.left - i.right, b.height - i.top - i.bottom)
} catch (e: Exception) {
Rectangle(0, 0, 1280, 720)
}
/**
* The desktop icon grid's spacing, read from the desktop's own icon view (so it follows the
* icon size and spacing Windows is really using), falling back to the system icon spacing.
* Java can't call Windows directly without extra libraries, so a hidden PowerShell asks once
* at startup, in the background; snapping just skips until the answer is in.
*/
object DesktopGrid {
@Volatile private var cached: Pair<Float, Float>? = null
@Volatile private var started = false
fun spacing(): Pair<Float, Float>? {
if (!started) refreshAsync()
return cached
}
@Synchronized
fun refreshAsync() {
if (started) return
started = true
Thread {
try {
val script = """
Add-Type -TypeDefinition @'
using System; using System.Runtime.InteropServices;
public static class WircGrid {
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern IntPtr FindWindow(string c, string n);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern IntPtr FindWindowEx(IntPtr p, IntPtr a, string c, string n);
[DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr h, int m, IntPtr w, IntPtr l);
[DllImport("user32.dll")] static extern bool SystemParametersInfo(uint a, uint p, out int v, uint i);
public static string Get() {
IntPtr dv = FindWindowEx(FindWindow("Progman", null), IntPtr.Zero, "SHELLDLL_DefView", null);
IntPtr w = IntPtr.Zero;
while (dv == IntPtr.Zero) { w = FindWindowEx(IntPtr.Zero, w, "WorkerW", null); if (w == IntPtr.Zero) break; dv = FindWindowEx(w, IntPtr.Zero, "SHELLDLL_DefView", null); }
IntPtr lv = dv == IntPtr.Zero ? IntPtr.Zero : FindWindowEx(dv, IntPtr.Zero, "SysListView32", null);
if (lv != IntPtr.Zero) { long r = SendMessage(lv, 0x1033, IntPtr.Zero, IntPtr.Zero).ToInt64(); long cx = r & 0xFFFF, cy = (r >> 16) & 0xFFFF; if (cx > 0 && cy > 0) return cx + "," + cy; }
int sx, sy; SystemParametersInfo(0x000D, 0, out sx, 0); SystemParametersInfo(0x0018, 0, out sy, 0);
return sx + "," + sy;
}
}
'@
[WircGrid]::Get()
""".trimIndent()
val encoded = Base64.getEncoder().encodeToString(script.toByteArray(Charsets.UTF_16LE))
val proc = ProcessBuilder("powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded)
.redirectErrorStream(true).start()
val out = proc.inputStream.bufferedReader().readText()
proc.waitFor(30, TimeUnit.SECONDS)
val m = Regex("""(\d+),(\d+)""").findAll(out).lastOrNull() ?: return@Thread
val px = m.groupValues[1].toFloat()
val py = m.groupValues[2].toFloat()
// Physical pixels at the primary screen's scaling -> the logical units windows use
val scale = try {
GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration.defaultTransform.scaleX.toFloat()
} catch (e: Exception) { 1f }.coerceAtLeast(0.5f)
if (px > 8 && py > 8) cached = (px / scale) to (py / scale)
} catch (e: Exception) {
// No grid info: windows just don't snap to it
}
}.apply { isDaemon = true; name = "wIRC desktop grid" }.start()
}
}
/** Whole lines that fit in [height] at [lineHeight], with a little slack for rounding. */
internal fun wholeLines(height: Dp, lineHeight: Dp): Int =
floor((height.value + 0.5f) / lineHeight.value).toInt().coerceAtLeast(1)
Binary file not shown.
Binary file not shown.
+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.