Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bcd1a7bcd | ||
|
|
026e349db8 | ||
|
|
efad80e141 | ||
|
|
3538274805 |
+2
-2
@@ -6,7 +6,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "com.tpuahsiew.wirc"
|
||||
version = "0.1.8"
|
||||
version = "0.1.12"
|
||||
|
||||
// No project-level repositories{} block here: settings.gradle.kts sets
|
||||
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
|
||||
@@ -38,7 +38,7 @@ compose.desktop {
|
||||
appResourcesRootDir.set(project.file("installerResources"))
|
||||
targetFormats(TargetFormat.Msi, TargetFormat.Exe)
|
||||
packageName = "wIRC"
|
||||
packageVersion = "0.1.8"
|
||||
packageVersion = "0.1.12"
|
||||
windows {
|
||||
menuGroup = "wIRC"
|
||||
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
|
||||
|
||||
+16
-6
@@ -51,6 +51,8 @@ fun main() = application {
|
||||
// Loaded once here (not per-window) so the alias map is truly global - every
|
||||
// ChannelWindow's command router reads from this same AliasRepository singleton.
|
||||
AliasRepository.init()
|
||||
// Ask Windows for the desktop icon grid now, so the first window drag can already snap to it
|
||||
ui.DesktopGrid.refreshAsync()
|
||||
|
||||
val repo = remember { ServerRepository(appRoot) }
|
||||
val prefsRepo = remember { WindowPrefsRepository(appRoot) }
|
||||
@@ -58,9 +60,9 @@ fun main() = application {
|
||||
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
|
||||
var mainUnread by remember { mutableStateOf(0) }
|
||||
|
||||
// Full shutdown: same steps /quit (bare, no args) already runs from a channel window -
|
||||
// disconnect every server, then end the process. Shared here so the "Exit wIRC" button
|
||||
// and every channel window's /quit reuse the exact same logic instead of duplicating it.
|
||||
// Full shutdown for the "Exit wIRC" button: disconnect every server (which also writes
|
||||
// all scrollback to disk), then end the process. A bare /quit in a channel window only
|
||||
// disconnects that window's server - see ConnectionManager.disconnect.
|
||||
val shutdownApp: () -> Unit = {
|
||||
ConnectionManager.disconnectAll()
|
||||
exitApplication()
|
||||
@@ -85,6 +87,9 @@ fun main() = application {
|
||||
// ("wirc-main__main"), same as every channel and PM window - see WindowPlacement.kt.
|
||||
val mainWindowState = rememberPersistentWindowState(prefsRepo, MAIN_BUFFER_KEY.storageId(), DpSize(800.dp, 700.dp))
|
||||
val mainDisplaySettings by prefsRepo.settingsFor(MAIN_BUFFER_KEY.storageId()).collectAsState()
|
||||
// Snap to the desktop grid after a move, and to whole lines after a resize
|
||||
val mainLineGap = ui.rememberLineGap()
|
||||
ui.WindowSnapping(mainWindowState, mainLineGap, osFrame = true)
|
||||
|
||||
// There's no persistent tray/notification surface on this build, so closing the main
|
||||
// server-list window quits the whole app (and every channel window with it) - the
|
||||
@@ -98,6 +103,7 @@ fun main() = application {
|
||||
icon = painterResource("icon.png"),
|
||||
state = mainWindowState
|
||||
) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) {
|
||||
MaterialTheme(colorScheme = neutralDarkScheme) {
|
||||
Surface {
|
||||
MainScreen(
|
||||
@@ -108,11 +114,13 @@ fun main() = application {
|
||||
openBuffers = openBuffers + BufferKey(serverId, channelName)
|
||||
},
|
||||
onExitApp = shutdownApp,
|
||||
onUnreadChange = { mainUnread = it }
|
||||
onUnreadChange = { mainUnread = it },
|
||||
onFontChange = { id -> prefsRepo.setFontId(MAIN_BUFFER_KEY.storageId(), id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openBuffers.forEach { bufferKey ->
|
||||
key(bufferKey) {
|
||||
@@ -122,8 +130,10 @@ fun main() = application {
|
||||
colorScheme = neutralDarkScheme,
|
||||
onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey },
|
||||
onCloseSelf = { openBuffers = openBuffers - bufferKey },
|
||||
onQuitApp = {
|
||||
exitApplication()
|
||||
// openBuffers is read when /quit runs, so every window open at that moment
|
||||
// gets its "Disconnected" line, even one that hasn't had a line yet.
|
||||
onDisconnectServer = {
|
||||
ConnectionManager.disconnect(bufferKey.serverId, openBuffers)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ object AliasRepository {
|
||||
return aliases.size
|
||||
}
|
||||
|
||||
/** True if [command] (without the leading '/') is an alias - those run locally, no server needed. */
|
||||
fun hasAlias(command: String): Boolean {
|
||||
val key = command.lowercase()
|
||||
return key != RESERVED_RELOAD && key in aliases
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if [command] (without the leading '/') matched a known alias and its
|
||||
* action was run - callers should treat that as "handled, don't fall through to IRC
|
||||
|
||||
@@ -10,10 +10,18 @@ import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
// Font sizes offered in the settings dialog (whole sizes only, same range as QuickClaude)
|
||||
const val MIN_FONT_SIZE = 10
|
||||
const val MAX_FONT_SIZE = 18
|
||||
|
||||
private fun pickerFontSize(size: Float): Float =
|
||||
Math.round(size).coerceIn(MIN_FONT_SIZE, MAX_FONT_SIZE).toFloat()
|
||||
|
||||
data class WindowDisplaySettings(
|
||||
val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque
|
||||
val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white
|
||||
val fontSize: Float = 14f,
|
||||
val fontId: String = "monospace", // see ui/AppFonts.kt; unknown/missing ids fall back to monospace
|
||||
val showUserList: Boolean = true,
|
||||
val mentionHighlightColorArgb: Int = 0x40FFA500.toInt(), // translucent orange
|
||||
val nickColorsEnabled: Boolean = true,
|
||||
@@ -62,7 +70,9 @@ class WindowPrefsRepository(rootDir: File) {
|
||||
|
||||
fun setTextColorMircIndex(bufferKeyId: String, index: Int) = update(bufferKeyId) { it.copy(textColorMircIndex = index) }
|
||||
|
||||
fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = fontSize) }
|
||||
fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = pickerFontSize(fontSize)) }
|
||||
|
||||
fun setFontId(bufferKeyId: String, fontId: String) = update(bufferKeyId) { it.copy(fontId = fontId) }
|
||||
|
||||
fun setShowUserList(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showUserList = show) }
|
||||
|
||||
@@ -106,7 +116,9 @@ class WindowPrefsRepository(rootDir: File) {
|
||||
stored[key] = WindowDisplaySettings(
|
||||
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
|
||||
textColorMircIndex = o.optInt("textColorMircIndex", 0),
|
||||
fontSize = o.optDouble("fontSize", 14.0).toFloat(),
|
||||
// Sizes saved by the old slider (any value from 10 to 24) round to the nearest picker size
|
||||
fontSize = pickerFontSize(o.optDouble("fontSize", 14.0).toFloat()),
|
||||
fontId = o.optString("fontId", "monospace"),
|
||||
showUserList = o.optBoolean("showUserList", true),
|
||||
mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(),
|
||||
nickColorsEnabled = o.optBoolean("nickColorsEnabled", true),
|
||||
@@ -138,6 +150,7 @@ class WindowPrefsRepository(rootDir: File) {
|
||||
put("backgroundAlpha", settings.backgroundAlpha)
|
||||
put("textColorMircIndex", settings.textColorMircIndex)
|
||||
put("fontSize", settings.fontSize)
|
||||
put("fontId", settings.fontId)
|
||||
put("showUserList", settings.showUserList)
|
||||
put("mentionHighlightColorArgb", settings.mentionHighlightColorArgb)
|
||||
put("nickColorsEnabled", settings.nickColorsEnabled)
|
||||
|
||||
@@ -73,6 +73,17 @@ object ConnectionManager {
|
||||
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
|
||||
|
||||
// Servers that have a live connection (or one that's connecting/auto-reconnecting after
|
||||
// a network drop). A server leaves this set only through disconnect()/disconnectAll(),
|
||||
// and it's what wIRC main's server cards show as Connected / Disconnected.
|
||||
private val _connectedIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val connectedIds: StateFlow<Set<String>> = _connectedIds
|
||||
|
||||
// serverId -> channels we were in when /quit disconnected that server, so the next
|
||||
// connect() rejoins them (and the windows still open for them pick right back up)
|
||||
// instead of only the configured auto-join list.
|
||||
private val channelsToRejoin = ConcurrentHashMap<String, MutableSet<String>>()
|
||||
|
||||
// A /whois we sent and haven't seen the end of yet. headerShown tracks whether its
|
||||
// "WHOIS <nick>" header line is already in wIRC main.
|
||||
private class PendingWhois(val nick: String, val sentAtMillis: Long, var headerShown: Boolean)
|
||||
@@ -142,33 +153,100 @@ object ConnectionManager {
|
||||
existing.disconnect()
|
||||
}
|
||||
setOwnNick(config.id, config.nick)
|
||||
val conn = IrcConnection(
|
||||
// Channels from the connection a /quit ended, or from the one being replaced, so
|
||||
// they're rejoined along with the auto-join list.
|
||||
val rejoin = channelsToRejoin.remove(config.id).orEmpty() + existing?.trackedChannels().orEmpty()
|
||||
lateinit var conn: IrcConnection
|
||||
conn = IrcConnection(
|
||||
config = config,
|
||||
scope = scope,
|
||||
onLine = { line -> enqueue { handleLine(config.id, line) } },
|
||||
initialChannels = rejoin,
|
||||
// Lines and status updates from a connection that's already been disconnected
|
||||
// or replaced (its socket closing, a last line in flight) are dropped, so
|
||||
// nothing shows up after the "Disconnected" line or overwrites that status.
|
||||
onLine = { line -> if (connections[config.id] === conn) enqueue { handleLine(config.id, line) } },
|
||||
onStatus = { msg ->
|
||||
if (connections[config.id] === conn) {
|
||||
_statuses.update { it + (config.id to msg) }
|
||||
// Connection progress and errors aren't tied to any channel - wIRC main.
|
||||
enqueue { addMainLine(MessageKind.SYSTEM, null, "[${config.label}] $msg") }
|
||||
}
|
||||
}
|
||||
)
|
||||
connections[config.id] = conn
|
||||
_connectedIds.update { it + config.id }
|
||||
conn.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare /quit: disconnects this one server and nothing else. Windows stay open and keep
|
||||
* their scrollback; every buffer of this server (plus [openKeys], the open windows, in
|
||||
* case one hasn't had a line yet) gets a "Disconnected" line, and so does wIRC main.
|
||||
* There's no auto-reconnect afterward - the next connect() (the server card's Connect
|
||||
* button) rejoins the channels we were in. History is written to disk right away, the
|
||||
* same as disconnectAll() does on exit.
|
||||
*/
|
||||
fun disconnect(serverId: String, openKeys: Collection<BufferKey> = emptyList()) {
|
||||
val conn = connections.remove(serverId) ?: return
|
||||
conn.disconnect()
|
||||
channelsToRejoin[serverId] = conn.trackedChannels().toMutableSet()
|
||||
_connectedIds.update { it - serverId }
|
||||
_statuses.update { it + (serverId to "Disconnected") }
|
||||
val label = conn.config.label
|
||||
enqueue {
|
||||
pendingWhois.remove(serverId)
|
||||
val now = System.currentTimeMillis()
|
||||
val keys = _buffers.value.keys.filter { it.serverId == serverId } +
|
||||
openKeys.filter { it.serverId == serverId }
|
||||
keys.distinct().forEach { key ->
|
||||
addMessage(key, IrcMessage(now, MessageKind.SYSTEM, null, "Disconnected from $label"))
|
||||
}
|
||||
// We're no longer in any channel, so the user lists are stale - the NAMES reply
|
||||
// on rejoin fills them again.
|
||||
_buffers.update { map ->
|
||||
map.mapValues { (key, buf) -> if (key.serverId == serverId) buf.copy(users = emptyList()) else buf }
|
||||
}
|
||||
addMainLine(MessageKind.SYSTEM, null, "[$label] Disconnected")
|
||||
// Runs on the bufferOps queue after the lines above, so they're in the save.
|
||||
flushAllSaves()
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnectAll() {
|
||||
connections.values.forEach { it.disconnect() }
|
||||
connections.clear()
|
||||
_connectedIds.value = emptySet()
|
||||
flushAllSaves()
|
||||
}
|
||||
|
||||
fun isConnected(serverId: String): Boolean = connections.containsKey(serverId)
|
||||
|
||||
// /part from a channel window. While disconnected there's no PART to send, but the
|
||||
// channel is dropped from the rejoin list so reconnecting doesn't rejoin it.
|
||||
fun partChannel(serverId: String, channel: String, reason: String) {
|
||||
val conn = connectionFor(serverId)
|
||||
if (conn != null) conn.partChannel(channel, reason)
|
||||
else channelsToRejoin[serverId]?.remove(channel)
|
||||
}
|
||||
|
||||
// /join from a channel window. A plain channel list is tracked (see
|
||||
// IrcConnection.joinChannel) so a reconnect rejoins it; one with a key is sent as-is,
|
||||
// since rejoining it later without the key wouldn't work anyway.
|
||||
fun joinChannels(serverId: String, args: String) {
|
||||
val conn = connectionFor(serverId) ?: return
|
||||
if (args.contains(' ')) conn.send("JOIN $args")
|
||||
else args.split(',').filter { it.isNotBlank() }.forEach { conn.joinChannel(it) }
|
||||
}
|
||||
|
||||
fun connectionFor(serverId: String): IrcConnection? = connections[serverId]
|
||||
|
||||
// IRC servers don't echo PRIVMSG back to its sender, so a sent message has to be
|
||||
// added to our own buffer locally - otherwise it would never show up in the window
|
||||
// that sent it.
|
||||
fun sendChannelMessage(serverId: String, target: String, text: String) {
|
||||
connectionFor(serverId)?.sendMessage(target, text)
|
||||
// No local echo while disconnected - the line was never sent.
|
||||
val conn = connectionFor(serverId) ?: return
|
||||
conn.sendMessage(target, text)
|
||||
val myNick = ownNicks[serverId] ?: return
|
||||
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
|
||||
enqueue { addOutgoing(serverId, target, msg, "-> *$target* $text") }
|
||||
@@ -178,7 +256,8 @@ object ConnectionManager {
|
||||
// CTCP ACTION wrapper, while the buffer should just hold the plain action text
|
||||
// (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix).
|
||||
fun sendChannelAction(serverId: String, target: String, action: String) {
|
||||
connectionFor(serverId)?.sendMessage(target, "\u0001ACTION $action\u0001")
|
||||
val conn = connectionFor(serverId) ?: return
|
||||
conn.sendMessage(target, "\u0001ACTION $action\u0001")
|
||||
val myNick = ownNicks[serverId] ?: return
|
||||
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
|
||||
enqueue { addOutgoing(serverId, target, msg, "-> *$target* * $myNick $action") }
|
||||
|
||||
@@ -50,6 +50,9 @@ private fun translateOnConnectCommand(cmd: String): String {
|
||||
class IrcConnection(
|
||||
val config: ServerConfig,
|
||||
private val scope: CoroutineScope,
|
||||
// Channels to rejoin on top of config.autoJoinChannels - the ones a previous connection
|
||||
// to this server was in (see ConnectionManager.connect).
|
||||
initialChannels: Collection<String> = emptyList(),
|
||||
private val onLine: (ParsedLine) -> Unit,
|
||||
private val onStatus: (String) -> Unit
|
||||
) {
|
||||
@@ -60,13 +63,17 @@ class IrcConnection(
|
||||
@Volatile private var socket: Socket? = null
|
||||
@Volatile private var writer: PrintWriter? = null
|
||||
private var readJob: Job? = null
|
||||
private var wantConnected = false
|
||||
// @Volatile: disconnect() clears it from the UI thread while the read loop checks it on
|
||||
// an IO thread - it's what stops the loop from auto-reconnecting after a /quit.
|
||||
@Volatile private var wantConnected = false
|
||||
private var backoffMillis = 2000L
|
||||
|
||||
// Every channel we're currently in, whether from config.autoJoinChannels or joined
|
||||
// manually mid-session - so a reconnect rejoins everything, not just the original
|
||||
// auto-join list.
|
||||
private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet<String>()
|
||||
private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet<String>(initialChannels)
|
||||
|
||||
fun trackedChannels(): Set<String> = joinedChannels.toSet()
|
||||
|
||||
// Guards runPostRegistration() against running twice for the same connection cycle
|
||||
// (376 and 422 are mutually exclusive per RFC, but this is cheap insurance) - reset
|
||||
@@ -121,6 +128,9 @@ class IrcConnection(
|
||||
Socket(config.host, config.port)
|
||||
}
|
||||
socket = sock
|
||||
// disconnect() can't interrupt a socket that's still being opened, so
|
||||
// check here before registering on a connection nobody wants anymore.
|
||||
ensureActive()
|
||||
val out = PrintWriter(OutputStreamWriter(sock.getOutputStream(), Charsets.UTF_8), true)
|
||||
writer = out
|
||||
|
||||
@@ -161,6 +171,9 @@ class IrcConnection(
|
||||
handleRawLine(line)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// After disconnect() the read fails because we closed the socket
|
||||
// ourselves - that's not an error worth reporting.
|
||||
if (!wantConnected) break
|
||||
onStatus("Connection error: ${e.message}")
|
||||
ircDebug(config.id, "connection dropped: ${e.message} - tracked channels at drop time: $joinedChannels")
|
||||
} finally {
|
||||
@@ -277,6 +290,9 @@ class IrcConnection(
|
||||
try { writer?.println("QUIT :leaving") } catch (_: Exception) {}
|
||||
readJob?.cancel()
|
||||
closeSocketQuietly()
|
||||
// Ends the outbox consumer - this connection is never reused (a reconnect
|
||||
// makes a new IrcConnection), so it would otherwise idle forever.
|
||||
outbox.close()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.platform.Font
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Fonts offered in each window's settings. Stored in window_prefs.json by [id], so the ids
|
||||
* must never change once shipped.
|
||||
*
|
||||
* Two kinds:
|
||||
* - Windows fonts (Segoe UI, Tahoma, Tw Cen MT) are Microsoft-licensed, so they are NOT
|
||||
* bundled. They are loaded straight out of the Windows font folders at runtime and only
|
||||
* listed when the file is actually there (Tw Cen MT only exists if Office is installed).
|
||||
* - Rounded fonts (Nunito, Quicksand, Varela Round) are SIL OFL licensed and bundled in
|
||||
* src/main/resources/fonts with their license files.
|
||||
*
|
||||
* Anything missing or failing to load falls back to Monospace, so a font uninstalled after
|
||||
* being picked can never crash a window.
|
||||
*/
|
||||
data class FontOption(val id: String, val label: String, val family: FontFamily)
|
||||
|
||||
object AppFonts {
|
||||
const val DEFAULT_ID = "monospace"
|
||||
|
||||
private val fontDirs: List<File> = buildList {
|
||||
add(File(System.getenv("WINDIR") ?: "C:\\Windows", "Fonts"))
|
||||
System.getenv("LOCALAPPDATA")?.let { add(File(it, "Microsoft\\Windows\\Fonts")) }
|
||||
}
|
||||
|
||||
/** Case-insensitive lookup across the system and per-user font folders. */
|
||||
private fun findSystemFont(name: String): File? {
|
||||
for (dir in fontDirs) {
|
||||
val direct = File(dir, name)
|
||||
if (direct.isFile) return direct
|
||||
dir.listFiles()?.firstOrNull { it.name.equals(name, ignoreCase = true) }?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun systemFamily(regular: String, bold: String?): FontFamily? {
|
||||
val reg = findSystemFont(regular) ?: return null
|
||||
return try {
|
||||
val fonts = mutableListOf(Font(reg, FontWeight.Normal))
|
||||
bold?.let { findSystemFont(it) }?.let { fonts.add(Font(it, FontWeight.Bold)) }
|
||||
FontFamily(fonts)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun bundledFamily(regular: String, bold: String?): FontFamily? = try {
|
||||
val fonts = mutableListOf(Font("fonts/$regular", FontWeight.Normal))
|
||||
bold?.let { fonts.add(Font("fonts/$it", FontWeight.Bold)) }
|
||||
FontFamily(fonts)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** Built once at startup; the font folders don't change while wIRC is running. */
|
||||
val options: List<FontOption> by lazy {
|
||||
buildList {
|
||||
add(FontOption(DEFAULT_ID, "Monospace", FontFamily.Monospace))
|
||||
systemFamily("segoeui.ttf", "segoeuib.ttf")?.let { add(FontOption("segoe-ui", "Segoe UI", it)) }
|
||||
systemFamily("tahoma.ttf", "tahomabd.ttf")?.let { add(FontOption("tahoma", "Tahoma", it)) }
|
||||
systemFamily("TCM_____.TTF", "TCB_____.TTF")?.let { add(FontOption("tw-cen-mt", "Tw Cen MT", it)) }
|
||||
bundledFamily("Nunito-Regular.ttf", "Nunito-Bold.ttf")?.let { add(FontOption("nunito", "Nunito (rounded)", it)) }
|
||||
bundledFamily("Quicksand-Regular.ttf", "Quicksand-Bold.ttf")?.let { add(FontOption("quicksand", "Quicksand (rounded)", it)) }
|
||||
bundledFamily("VarelaRound-Regular.ttf", null)?.let { add(FontOption("varela-round", "Varela Round (rounded)", it)) }
|
||||
}
|
||||
}
|
||||
|
||||
fun familyFor(id: String): FontFamily =
|
||||
options.firstOrNull { it.id == id }?.family ?: FontFamily.Monospace
|
||||
}
|
||||
|
||||
/**
|
||||
* The font list used in both the channel-window settings and wIRC main's Font dialog. Each
|
||||
* name is drawn in its own font as a live preview; plain square-cornered rows, no dropdown.
|
||||
*/
|
||||
@Composable
|
||||
fun FontPickerList(selectedId: String, textColor: Color, onPick: (String) -> Unit) {
|
||||
val effectiveId = if (AppFonts.options.any { it.id == selectedId }) selectedId else AppFonts.DEFAULT_ID
|
||||
Column {
|
||||
AppFonts.options.forEach { option ->
|
||||
Text(
|
||||
text = option.label,
|
||||
color = textColor,
|
||||
fontFamily = option.family,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (option.id == effectiveId) Color.White.copy(alpha = 0.18f) else Color.Transparent)
|
||||
.clickable { onPick(option.id) }
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,8 @@ import androidx.compose.ui.window.rememberDialogState
|
||||
import data.ChannelBuffer
|
||||
import data.ChannelUser
|
||||
import data.IrcMessage
|
||||
import data.MAX_FONT_SIZE
|
||||
import data.MIN_FONT_SIZE
|
||||
import data.MessageKind
|
||||
import data.WindowDisplaySettings
|
||||
import irc.extractImageUrls
|
||||
@@ -102,6 +104,7 @@ import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Title-bar button fade (nicklist / settings / minimize / close). The buttons sit at
|
||||
// TITLE_BAR_BUTTON_REST_ALPHA until hovered, then animate to TITLE_BAR_BUTTON_HOVER_ALPHA
|
||||
@@ -195,6 +198,7 @@ fun WindowScope.ChannelScreen(
|
||||
onAlphaChange: (Float) -> Unit,
|
||||
onTextColorChange: (Int) -> Unit,
|
||||
onFontSizeChange: (Float) -> Unit,
|
||||
onFontChange: (String) -> Unit,
|
||||
onShowUserListChange: (Boolean) -> Unit,
|
||||
onNickColorsToggle: (Boolean) -> Unit,
|
||||
onMentionHighlightColorChange: (Int) -> Unit,
|
||||
@@ -370,7 +374,7 @@ fun WindowScope.ChannelScreen(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(channelName, color = textColor, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall)
|
||||
Text(channelName, color = textColor, fontFamily = AppFonts.familyFor(displaySettings.fontId), style = MaterialTheme.typography.labelSmall)
|
||||
Row {
|
||||
FadingTitleBarButton(onClick = { onShowUserListChange(!displaySettings.showUserList) }) {
|
||||
Icon(Icons.Default.Person, contentDescription = "Toggle user list", tint = textColor, modifier = Modifier.size(16.dp))
|
||||
@@ -416,10 +420,13 @@ fun WindowScope.ChannelScreen(
|
||||
Surface {
|
||||
WindowSettingsPanel(
|
||||
displaySettings = displaySettings,
|
||||
textColor = textColor,
|
||||
// Always white: the dialog is black, so the window's own text
|
||||
// color (black, say) would make every label invisible here
|
||||
textColor = Color.White,
|
||||
onAlphaChange = onAlphaChange,
|
||||
onTextColorChange = onTextColorChange,
|
||||
onFontSizeChange = onFontSizeChange,
|
||||
onFontChange = onFontChange,
|
||||
onNickColorsToggle = onNickColorsToggle,
|
||||
onMentionHighlightColorChange = onMentionHighlightColorChange,
|
||||
onShowImagePreviewsChange = onShowImagePreviewsChange
|
||||
@@ -453,7 +460,7 @@ fun WindowScope.ChannelScreen(
|
||||
Text(
|
||||
text = (if (user.isOp) "@" else if (user.isVoice) "+" else "") + user.nick,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = AppFonts.familyFor(displaySettings.fontId),
|
||||
fontSize = displaySettings.fontSize.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -491,7 +498,7 @@ fun WindowScope.ChannelScreen(
|
||||
onValueChange = { input = it },
|
||||
singleLine = true,
|
||||
textStyle = androidx.compose.ui.text.TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = AppFonts.familyFor(displaySettings.fontId),
|
||||
color = textColor,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
),
|
||||
@@ -840,11 +847,11 @@ fun MessageScrollback(
|
||||
// then converts that now-stale pixel value using the NEW density, so the
|
||||
// two silently disagreed until the window (and this remember scope) was
|
||||
// recreated by closing and reopening it.
|
||||
val lineHeightPx = remember(displaySettings.fontSize, density) {
|
||||
val lineHeightPx = remember(displaySettings.fontSize, displaySettings.fontId, density) {
|
||||
textMeasurer.measure(
|
||||
text = "Mg",
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = AppFonts.familyFor(displaySettings.fontId),
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
).size.height
|
||||
@@ -857,10 +864,14 @@ fun MessageScrollback(
|
||||
// real cause was a race between the scroll effect and the LazyColumn's
|
||||
// own layout catching up to new items - see the scroll effects below.
|
||||
val snappedHeight = if (lineHeightDp > 0.dp) {
|
||||
lineHeightDp * floor(maxHeight / lineHeightDp).toInt()
|
||||
minOf(maxHeight, lineHeightDp * wholeLines(maxHeight, lineHeightDp))
|
||||
} else {
|
||||
maxHeight
|
||||
}
|
||||
// Tells the window how much space is left under the last whole line, so a resize can
|
||||
// trim the window itself to fit (see WindowSnapping.kt)
|
||||
val lineGap = LocalLineGap.current
|
||||
SideEffect { lineGap?.gap = maxHeight - snappedHeight }
|
||||
|
||||
// NOTE: this used to also re-snap the scroll *offset* to the first
|
||||
// visible item on every resize/font-size change, to fix a partial line
|
||||
@@ -875,7 +886,7 @@ fun MessageScrollback(
|
||||
// second independent one.
|
||||
|
||||
val textStyle = androidx.compose.ui.text.TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = AppFonts.familyFor(displaySettings.fontId),
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
val selection = selectionState.selection
|
||||
@@ -1029,7 +1040,10 @@ fun MessageScrollback(
|
||||
) { msg ->
|
||||
val ts = timeFormat.format(Date(msg.timestampMillis))
|
||||
val bodyAnnotated = parseMircToAnnotatedString(msg.rawText, textColor)
|
||||
val mentioned = isMention(msg.rawText)
|
||||
// Only real messages get the mention tint. Join/part/quit/nick/mode lines
|
||||
// always contain your nick when they're about you ("you joined #chan"),
|
||||
// which used to light up every one of your own joins.
|
||||
val mentioned = msg.kind in MENTION_KINDS && isMention(msg.rawText)
|
||||
val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor
|
||||
val mutedColor = textColor.copy(alpha = 0.7f)
|
||||
val prefix = rowPrefix(msg, ts, mutedColor, nickColor)
|
||||
@@ -1397,6 +1411,8 @@ private fun MentionColorPickerDialog(currentArgb: Int, onDismiss: () -> Unit, on
|
||||
)
|
||||
}
|
||||
|
||||
private val MENTION_KINDS = setOf(MessageKind.CHAT, MessageKind.ACTION, MessageKind.NOTICE)
|
||||
|
||||
@Composable
|
||||
private fun WindowSettingsPanel(
|
||||
displaySettings: WindowDisplaySettings,
|
||||
@@ -1404,6 +1420,7 @@ private fun WindowSettingsPanel(
|
||||
onAlphaChange: (Float) -> Unit,
|
||||
onTextColorChange: (Int) -> Unit,
|
||||
onFontSizeChange: (Float) -> Unit,
|
||||
onFontChange: (String) -> Unit,
|
||||
onNickColorsToggle: (Boolean) -> Unit,
|
||||
onMentionHighlightColorChange: (Int) -> Unit,
|
||||
onShowImagePreviewsChange: (Boolean) -> Unit
|
||||
@@ -1433,13 +1450,27 @@ private fun WindowSettingsPanel(
|
||||
colors = sliderColors
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Font size", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||
Slider(
|
||||
value = displaySettings.fontSize,
|
||||
onValueChange = onFontSizeChange,
|
||||
valueRange = 10f..24f,
|
||||
colors = sliderColors
|
||||
)
|
||||
Text("Font size:", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||
// Fixed sizes 10 to 18 (same as QuickClaude's Font size menu), square boxes in a row
|
||||
val currentSize = displaySettings.fontSize.roundToInt()
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
for (size in MIN_FONT_SIZE..MAX_FONT_SIZE) {
|
||||
val selected = size == currentSize
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.background(if (selected) Color.White.copy(alpha = 0.25f) else Color.Transparent)
|
||||
.border(1.dp, if (selected) Color.White else Color.White.copy(alpha = 0.35f))
|
||||
.clickableNoRipple { onFontSizeChange(size.toFloat()) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(size.toString(), color = textColor, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Font:", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||
FontPickerList(displaySettings.fontId, textColor, onFontChange)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Text color:", color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||
FlowSwatchGrid(
|
||||
|
||||
@@ -3,6 +3,7 @@ package ui
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -20,9 +21,20 @@ import data.WindowPrefsRepository
|
||||
import data.isChannelName
|
||||
import data.storageId
|
||||
import irc.ConnectionManager
|
||||
import irc.IrcConnection
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val NOT_CONNECTED_TEXT = "Not connected - use Connect in wIRC main to reconnect"
|
||||
|
||||
// Runs [action] on this server's connection, or shows the not-connected line in this
|
||||
// window when there isn't one (e.g. the user-list kick/ban/op menu after a /quit).
|
||||
private fun withConnection(serverId: String, windowName: String, action: (IrcConnection) -> Unit) {
|
||||
val conn = ConnectionManager.connectionFor(serverId)
|
||||
if (conn != null) action(conn)
|
||||
else ConnectionManager.addSystemMessage(serverId, windowName, NOT_CONNECTED_TEXT)
|
||||
}
|
||||
|
||||
/**
|
||||
* One of these is composed per open buffer key (see Main.kt) - each is a real, independent
|
||||
* top-level OS window, same intent as ChannelWindowActivity.kt's per-channel Activity on
|
||||
@@ -36,7 +48,7 @@ fun ChannelWindow(
|
||||
colorScheme: ColorScheme,
|
||||
onOpenBuffer: (BufferKey) -> Unit,
|
||||
onCloseSelf: () -> Unit,
|
||||
onQuitApp: () -> Unit
|
||||
onDisconnectServer: () -> Unit
|
||||
) {
|
||||
val serverId = bufferKey.serverId
|
||||
val channelName = bufferKey.name
|
||||
@@ -63,6 +75,9 @@ fun ChannelWindow(
|
||||
// Restored from window_prefs.json (or cascaded if there's no usable saved position)
|
||||
// and saved back on move/resize/close - see WindowPlacement.kt.
|
||||
val windowState = rememberPersistentWindowState(prefsRepo, bufferKey.storageId(), DpSize(800.dp, 600.dp))
|
||||
// Snap to the desktop grid after a move, and to whole lines after a resize
|
||||
val lineGap = rememberLineGap()
|
||||
WindowSnapping(windowState, lineGap)
|
||||
|
||||
// undecorated + transparent trade the native OS title bar for real per-pixel window
|
||||
// transparency (Windows only honors transparency on undecorated windows) - ChannelScreen
|
||||
@@ -78,6 +93,7 @@ fun ChannelWindow(
|
||||
transparent = true,
|
||||
resizable = true
|
||||
) {
|
||||
CompositionLocalProvider(LocalLineGap provides lineGap) {
|
||||
MaterialTheme(colorScheme = colorScheme) {
|
||||
ChannelScreen(
|
||||
windowState = windowState,
|
||||
@@ -87,7 +103,19 @@ fun ChannelWindow(
|
||||
displaySettings = displaySettings,
|
||||
colorScheme = colorScheme,
|
||||
ownNick = ownNick,
|
||||
onSend = { text ->
|
||||
onSend = onSend@{ text ->
|
||||
// Everything except /reload, aliases and /part (which only closes
|
||||
// the window) needs the server - while disconnected it gets this
|
||||
// line in this window instead of going nowhere.
|
||||
val needsServer = if (text.startsWith("/")) {
|
||||
val command = text.substring(1).substringBefore(' ').uppercase()
|
||||
command != "RELOAD" && command !in setOf("PART", "EXIT", "CLOSE") &&
|
||||
!AliasRepository.hasAlias(command)
|
||||
} else true
|
||||
if (needsServer && !ConnectionManager.isConnected(serverId)) {
|
||||
ConnectionManager.addSystemMessage(serverId, channelName, NOT_CONNECTED_TEXT)
|
||||
return@onSend
|
||||
}
|
||||
if (text.startsWith("/")) {
|
||||
val withoutSlash = text.substring(1)
|
||||
val spaceIdx = withoutSlash.indexOf(' ')
|
||||
@@ -112,19 +140,20 @@ fun ChannelWindow(
|
||||
"NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args")
|
||||
"PART", "EXIT", "CLOSE" -> {
|
||||
val target = args.ifBlank { channelName }
|
||||
ConnectionManager.connectionFor(serverId)?.partChannel(target, "leaving")
|
||||
ConnectionManager.partChannel(serverId, target, "leaving")
|
||||
if (args.isBlank() || target == channelName) onCloseSelf()
|
||||
}
|
||||
"QUIT" -> {
|
||||
if (rawArgs.isBlank()) {
|
||||
ConnectionManager.disconnectAll()
|
||||
onQuitApp()
|
||||
// Disconnects this window's server only - every
|
||||
// window stays open; see ConnectionManager.disconnect.
|
||||
onDisconnectServer()
|
||||
} else {
|
||||
ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs)
|
||||
}
|
||||
}
|
||||
"JOIN" -> {
|
||||
ConnectionManager.sendRaw(serverId, "JOIN $args")
|
||||
ConnectionManager.joinChannels(serverId, args)
|
||||
onOpenBuffer(BufferKey(serverId, args))
|
||||
}
|
||||
// All whois output goes to wIRC main, not this window.
|
||||
@@ -144,10 +173,10 @@ fun ChannelWindow(
|
||||
ConnectionManager.sendChannelMessage(serverId, channelName, text)
|
||||
}
|
||||
},
|
||||
onKick = { nick -> ConnectionManager.connectionFor(serverId)?.kick(channelName, nick) },
|
||||
onBan = { nick -> ConnectionManager.connectionFor(serverId)?.ban(channelName, "$nick!*@*") },
|
||||
onOp = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "+o", nick) },
|
||||
onDeop = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "-o", nick) },
|
||||
onKick = { nick -> withConnection(serverId, channelName) { it.kick(channelName, nick) } },
|
||||
onBan = { nick -> withConnection(serverId, channelName) { it.ban(channelName, "$nick!*@*") } },
|
||||
onOp = { nick -> withConnection(serverId, channelName) { it.setMode(channelName, "+o", nick) } },
|
||||
onDeop = { nick -> withConnection(serverId, channelName) { it.setMode(channelName, "-o", nick) } },
|
||||
onAlphaChange = { alpha ->
|
||||
scope.launch(Dispatchers.IO) { prefsRepo.setAlpha(bufferKey.storageId(), alpha) }
|
||||
},
|
||||
@@ -157,6 +186,9 @@ fun ChannelWindow(
|
||||
onFontSizeChange = { size ->
|
||||
scope.launch(Dispatchers.IO) { prefsRepo.setFontSize(bufferKey.storageId(), size) }
|
||||
},
|
||||
onFontChange = { id ->
|
||||
scope.launch(Dispatchers.IO) { prefsRepo.setFontId(bufferKey.storageId(), id) }
|
||||
},
|
||||
onShowUserListChange = { show ->
|
||||
scope.launch(Dispatchers.IO) { prefsRepo.setShowUserList(bufferKey.storageId(), show) }
|
||||
},
|
||||
@@ -173,3 +205,4 @@ fun ChannelWindow(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,16 @@ fun MainScreen(
|
||||
displaySettings: WindowDisplaySettings,
|
||||
onOpenChannel: (serverId: String, channelName: String) -> Unit,
|
||||
onExitApp: () -> Unit,
|
||||
onUnreadChange: (Int) -> Unit
|
||||
onUnreadChange: (Int) -> Unit,
|
||||
onFontChange: (String) -> Unit
|
||||
) {
|
||||
var showFontDialog by remember { mutableStateOf(false) }
|
||||
var servers by remember { mutableStateOf(repo.loadAll()) }
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
var editingServer by remember { mutableStateOf<ServerConfig?>(null) }
|
||||
var connectedIds by remember { mutableStateOf(setOf<String>()) }
|
||||
// From ConnectionManager rather than local state, so a /quit typed in a channel window
|
||||
// flips that server's card back to Disconnected with its Connect button.
|
||||
val connectedIds by ConnectionManager.connectedIds.collectAsState()
|
||||
var channelInputs by remember { mutableStateOf(mapOf<String, String>()) }
|
||||
var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -85,9 +89,14 @@ fun MainScreen(
|
||||
Text("+ Add Server")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// Same end result as typing "/quit" alone in a channel window - disconnects
|
||||
// every server and terminates the process - just reachable without needing
|
||||
// a channel window open first.
|
||||
// Font for the server messages box (channel windows pick theirs in their own
|
||||
// settings).
|
||||
TextButton(onClick = { showFontDialog = true }) {
|
||||
Text("Font")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// Disconnects every server and terminates the process. (A bare "/quit" in a
|
||||
// channel window only disconnects that window's server.)
|
||||
TextButton(onClick = onExitApp) {
|
||||
Text("Exit wIRC")
|
||||
}
|
||||
@@ -108,8 +117,9 @@ fun MainScreen(
|
||||
openedChannels = openedChannels[server.id] ?: emptyList(),
|
||||
onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) },
|
||||
onConnect = {
|
||||
// Also rejoins the channels this server was in before a /quit - their
|
||||
// windows are still open, and opening one that already is is a no-op.
|
||||
ConnectionManager.connect(server)
|
||||
connectedIds = connectedIds + server.id
|
||||
if (server.autoJoinChannels.isNotEmpty()) {
|
||||
// Auto-join sends JOIN correctly on connect (see IrcConnection),
|
||||
// but nothing else opens a window for it like the manual
|
||||
@@ -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) {
|
||||
ServerEditDialog(
|
||||
existing = editingServer,
|
||||
@@ -243,6 +272,8 @@ private fun ServerCard(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (!isConnected) {
|
||||
Text("Disconnected", style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Button(onClick = onConnect) { Text("Connect") }
|
||||
} else {
|
||||
Text("Connected", style = MaterialTheme.typography.bodySmall)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
import androidx.compose.ui.window.WindowPosition
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import java.awt.AWTEvent
|
||||
import java.awt.GraphicsEnvironment
|
||||
import java.awt.Rectangle
|
||||
import java.awt.Toolkit
|
||||
import java.awt.event.AWTEventListener
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Space left over under the last whole line of a window's scrollback, reported by
|
||||
* MessageScrollback (which already sizes the scrollback itself to whole lines). When a
|
||||
* resize ends, the window is trimmed by this much so the window itself fits whole lines too.
|
||||
*/
|
||||
class LineGap {
|
||||
@Volatile var gap: Dp = 0.dp
|
||||
}
|
||||
|
||||
val LocalLineGap = staticCompositionLocalOf<LineGap?> { null }
|
||||
|
||||
@Composable
|
||||
fun rememberLineGap(): LineGap = remember { LineGap() }
|
||||
|
||||
/**
|
||||
* After the mouse is let go at the end of a drag or resize:
|
||||
* - a move snaps the window's top-left corner to the desktop icon grid
|
||||
* - a resize trims the window to whole lines of scrollback text (keeping whichever edge
|
||||
* didn't move in place)
|
||||
* Channel windows are undecorated, so their moves and resizes happen inside the app (the
|
||||
* title-bar drag area and Compose's own edge resizer) and the mouse release reaches wIRC.
|
||||
* wIRC main has a normal Windows frame, whose drags Windows handles itself without telling
|
||||
* the app when the button comes up, so for it ([osFrame] = true) the snap happens once the
|
||||
* window has sat still for a moment instead.
|
||||
*/
|
||||
@Composable
|
||||
fun WindowSnapping(state: WindowState, lineGap: LineGap, osFrame: Boolean = false) {
|
||||
if (osFrame) {
|
||||
LaunchedEffect(state, lineGap) {
|
||||
var settledPos: WindowPosition = state.position
|
||||
var settledSize: DpSize = state.size
|
||||
snapshotFlow { state.position to state.size }.collectLatest {
|
||||
delay(OS_FRAME_SETTLE_MS)
|
||||
afterMoveOrResize(state, lineGap, settledPos, settledSize)
|
||||
settledPos = state.position
|
||||
settledSize = state.size
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
DisposableEffect(state, lineGap) {
|
||||
var startPos: WindowPosition? = null
|
||||
var startSize: DpSize? = null
|
||||
val listener = AWTEventListener { event ->
|
||||
val e = event as? MouseEvent ?: return@AWTEventListener
|
||||
if (e.button != MouseEvent.BUTTON1) return@AWTEventListener
|
||||
when (e.id) {
|
||||
MouseEvent.MOUSE_PRESSED -> {
|
||||
startPos = state.position
|
||||
startSize = state.size
|
||||
}
|
||||
MouseEvent.MOUSE_RELEASED -> {
|
||||
val p0 = startPos
|
||||
val s0 = startSize
|
||||
startPos = null
|
||||
startSize = null
|
||||
if (p0 != null && s0 != null) afterMoveOrResize(state, lineGap, p0, s0)
|
||||
}
|
||||
}
|
||||
}
|
||||
Toolkit.getDefaultToolkit().addAWTEventListener(listener, AWTEvent.MOUSE_EVENT_MASK)
|
||||
onDispose { Toolkit.getDefaultToolkit().removeAWTEventListener(listener) }
|
||||
}
|
||||
}
|
||||
|
||||
// How long wIRC main has to sit still after a move/resize before it snaps
|
||||
private const val OS_FRAME_SETTLE_MS = 400L
|
||||
|
||||
private fun afterMoveOrResize(state: WindowState, lineGap: LineGap, p0: WindowPosition, s0: DpSize) {
|
||||
if (state.isMinimized || state.placement != WindowPlacement.Floating) return
|
||||
val pos = state.position as? WindowPosition.Absolute ?: return
|
||||
val size = state.size
|
||||
if (!size.isSpecified || !s0.isSpecified) return
|
||||
val start = p0 as? WindowPosition.Absolute
|
||||
|
||||
val resized = (size.width - s0.width).value.let { it > 0.5f || it < -0.5f } ||
|
||||
(size.height - s0.height).value.let { it > 0.5f || it < -0.5f }
|
||||
|
||||
if (resized) {
|
||||
val gap = lineGap.gap
|
||||
if (gap > 0.5.dp && size.height - gap >= 120.dp) {
|
||||
// Dragged the top edge (top moved, bottom stayed): keep the bottom where it is
|
||||
val fromTop = start != null && (pos.y - start.y).value.let { it > 0.5f || it < -0.5f } &&
|
||||
((pos.y + size.height) - (start.y + s0.height)).value.let { it < 0.5f && it > -0.5f }
|
||||
state.size = DpSize(size.width, size.height - gap)
|
||||
if (fromTop) state.position = WindowPosition.Absolute(pos.x, pos.y + gap)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (start == null || (start.x == pos.x && start.y == pos.y)) return
|
||||
val (sx, sy) = DesktopGrid.spacing() ?: return
|
||||
val area = workAreaAt(pos.x.value, pos.y.value)
|
||||
val x = area.x + ((pos.x.value - area.x) / sx).roundToInt() * sx
|
||||
val y = area.y + ((pos.y.value - area.y) / sy).roundToInt() * sy
|
||||
if (x != pos.x.value || y != pos.y.value) state.position = WindowPosition.Absolute(x.dp, y.dp)
|
||||
}
|
||||
|
||||
// The usable area (taskbar excluded) of the screen the window's corner is on, in the same
|
||||
// logical units as WindowState positions.
|
||||
private fun workAreaAt(x: Float, y: Float): Rectangle = try {
|
||||
val ge = GraphicsEnvironment.getLocalGraphicsEnvironment()
|
||||
val config = ge.screenDevices.map { it.defaultConfiguration }
|
||||
.firstOrNull { it.bounds.contains(x.toInt(), y.toInt()) } ?: ge.defaultScreenDevice.defaultConfiguration
|
||||
val b = config.bounds
|
||||
val i = Toolkit.getDefaultToolkit().getScreenInsets(config)
|
||||
Rectangle(b.x + i.left, b.y + i.top, b.width - i.left - i.right, b.height - i.top - i.bottom)
|
||||
} catch (e: Exception) {
|
||||
Rectangle(0, 0, 1280, 720)
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop icon grid's spacing, read from the desktop's own icon view (so it follows the
|
||||
* icon size and spacing Windows is really using), falling back to the system icon spacing.
|
||||
* Java can't call Windows directly without extra libraries, so a hidden PowerShell asks once
|
||||
* at startup, in the background; snapping just skips until the answer is in.
|
||||
*/
|
||||
object DesktopGrid {
|
||||
@Volatile private var cached: Pair<Float, Float>? = null
|
||||
@Volatile private var started = false
|
||||
|
||||
fun spacing(): Pair<Float, Float>? {
|
||||
if (!started) refreshAsync()
|
||||
return cached
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun refreshAsync() {
|
||||
if (started) return
|
||||
started = true
|
||||
Thread {
|
||||
try {
|
||||
val script = """
|
||||
Add-Type -TypeDefinition @'
|
||||
using System; using System.Runtime.InteropServices;
|
||||
public static class WircGrid {
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern IntPtr FindWindow(string c, string n);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern IntPtr FindWindowEx(IntPtr p, IntPtr a, string c, string n);
|
||||
[DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr h, int m, IntPtr w, IntPtr l);
|
||||
[DllImport("user32.dll")] static extern bool SystemParametersInfo(uint a, uint p, out int v, uint i);
|
||||
public static string Get() {
|
||||
IntPtr dv = FindWindowEx(FindWindow("Progman", null), IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||
IntPtr w = IntPtr.Zero;
|
||||
while (dv == IntPtr.Zero) { w = FindWindowEx(IntPtr.Zero, w, "WorkerW", null); if (w == IntPtr.Zero) break; dv = FindWindowEx(w, IntPtr.Zero, "SHELLDLL_DefView", null); }
|
||||
IntPtr lv = dv == IntPtr.Zero ? IntPtr.Zero : FindWindowEx(dv, IntPtr.Zero, "SysListView32", null);
|
||||
if (lv != IntPtr.Zero) { long r = SendMessage(lv, 0x1033, IntPtr.Zero, IntPtr.Zero).ToInt64(); long cx = r & 0xFFFF, cy = (r >> 16) & 0xFFFF; if (cx > 0 && cy > 0) return cx + "," + cy; }
|
||||
int sx, sy; SystemParametersInfo(0x000D, 0, out sx, 0); SystemParametersInfo(0x0018, 0, out sy, 0);
|
||||
return sx + "," + sy;
|
||||
}
|
||||
}
|
||||
'@
|
||||
[WircGrid]::Get()
|
||||
""".trimIndent()
|
||||
val encoded = Base64.getEncoder().encodeToString(script.toByteArray(Charsets.UTF_16LE))
|
||||
val proc = ProcessBuilder("powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded)
|
||||
.redirectErrorStream(true).start()
|
||||
val out = proc.inputStream.bufferedReader().readText()
|
||||
proc.waitFor(30, TimeUnit.SECONDS)
|
||||
val m = Regex("""(\d+),(\d+)""").findAll(out).lastOrNull() ?: return@Thread
|
||||
val px = m.groupValues[1].toFloat()
|
||||
val py = m.groupValues[2].toFloat()
|
||||
// Physical pixels at the primary screen's scaling -> the logical units windows use
|
||||
val scale = try {
|
||||
GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration.defaultTransform.scaleX.toFloat()
|
||||
} catch (e: Exception) { 1f }.coerceAtLeast(0.5f)
|
||||
if (px > 8 && py > 8) cached = (px / scale) to (py / scale)
|
||||
} catch (e: Exception) {
|
||||
// No grid info: windows just don't snap to it
|
||||
}
|
||||
}.apply { isDaemon = true; name = "wIRC desktop grid" }.start()
|
||||
}
|
||||
}
|
||||
|
||||
/** Whole lines that fit in [height] at [lineHeight], with a little slack for rounding. */
|
||||
internal fun wholeLines(height: Dp, lineHeight: Dp): Int =
|
||||
floor((height.value + 0.5f) / lineHeight.value).toInt().coerceAtLeast(1)
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Quicksand Project Authors (https://github.com/andrew-paglinawan/QuicksandFamily), with Reserved Font Name “Quicksand”.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2023 The Varela Round Project Authors (https://github.com/alefalefalef/Varela-Round-Hebrew/), with Reserved Font Names 'Varela' and ‘Varela Round’.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user