Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d58b5772df | ||
|
|
fff75700ac | ||
|
|
ed5d9a9c78 | ||
|
|
03964ca313 | ||
|
|
78bd235a48 | ||
|
|
24f54574ec | ||
|
|
4ac7b75962 | ||
|
|
5bcd1a7bcd | ||
|
|
026e349db8 | ||
|
|
efad80e141 |
+5
-3
@@ -1,4 +1,4 @@
|
|||||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
kotlin("jvm") version "1.9.24"
|
kotlin("jvm") version "1.9.24"
|
||||||
@@ -6,7 +6,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = "com.tpuahsiew.wirc"
|
group = "com.tpuahsiew.wirc"
|
||||||
version = "0.1.9"
|
version = "0.1.17"
|
||||||
|
|
||||||
// No project-level repositories{} block here: settings.gradle.kts sets
|
// No project-level repositories{} block here: settings.gradle.kts sets
|
||||||
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
|
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
|
||||||
@@ -22,6 +22,8 @@ dependencies {
|
|||||||
implementation(compose.materialIconsExtended)
|
implementation(compose.materialIconsExtended)
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1")
|
||||||
implementation("org.json:json:20240303")
|
implementation("org.json:json:20240303")
|
||||||
|
// Native Windows calls that keep wIRC's windows off the taskbar (see AppTray.kt)
|
||||||
|
implementation("net.java.dev.jna:jna-platform:5.14.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -38,7 +40,7 @@ compose.desktop {
|
|||||||
appResourcesRootDir.set(project.file("installerResources"))
|
appResourcesRootDir.set(project.file("installerResources"))
|
||||||
targetFormats(TargetFormat.Msi, TargetFormat.Exe)
|
targetFormats(TargetFormat.Msi, TargetFormat.Exe)
|
||||||
packageName = "wIRC"
|
packageName = "wIRC"
|
||||||
packageVersion = "0.1.9"
|
packageVersion = "0.1.17"
|
||||||
windows {
|
windows {
|
||||||
menuGroup = "wIRC"
|
menuGroup = "wIRC"
|
||||||
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
|
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
|
||||||
|
|||||||
+63
-10
@@ -1,6 +1,7 @@
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -18,11 +19,14 @@ import data.AliasRepository
|
|||||||
import data.BufferKey
|
import data.BufferKey
|
||||||
import data.MAIN_BUFFER_KEY
|
import data.MAIN_BUFFER_KEY
|
||||||
import data.ServerRepository
|
import data.ServerRepository
|
||||||
|
import data.SessionState
|
||||||
|
import data.SessionStateRepository
|
||||||
import data.WindowPrefsRepository
|
import data.WindowPrefsRepository
|
||||||
import data.storageId
|
import data.storageId
|
||||||
import irc.ConnectionManager
|
import irc.ConnectionManager
|
||||||
import ui.ChannelWindow
|
import ui.ChannelWindow
|
||||||
import ui.MainScreen
|
import ui.MainScreen
|
||||||
|
import ui.TrayWindowBehavior
|
||||||
import ui.rememberPersistentWindowState
|
import ui.rememberPersistentWindowState
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -51,6 +55,8 @@ fun main() = application {
|
|||||||
// Loaded once here (not per-window) so the alias map is truly global - every
|
// 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,10 +64,34 @@ 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) }
|
||||||
|
|
||||||
|
// wIRC lives in the tray: no window is on the taskbar, and minimizing a window hides just
|
||||||
|
// that window (see TrayWindowBehavior). Hidden windows stay composed and connected, so
|
||||||
|
// nothing is lost while they're away; showing them again puts each back where it was.
|
||||||
|
// wIRC main starts hidden if it was hidden when wIRC last quit.
|
||||||
|
val sessionRepo = remember { SessionStateRepository(appRoot) }
|
||||||
|
val startSession = remember { sessionRepo.load() }
|
||||||
|
var mainHidden by remember { mutableStateOf(startSession.mainHidden) }
|
||||||
|
var hiddenBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
|
||||||
|
val anyHidden = mainHidden || openBuffers.any { it in hiddenBuffers }
|
||||||
|
val showAll: () -> Unit = { mainHidden = false; hiddenBuffers = emptySet() }
|
||||||
|
// Windows opened after this (e.g. a new PM) still show up
|
||||||
|
val hideAll: () -> Unit = { mainHidden = true; hiddenBuffers = openBuffers }
|
||||||
|
|
||||||
|
val saveSession: () -> Unit = {
|
||||||
|
sessionRepo.saveOnce(SessionState(mainHidden, ConnectionManager.connectedIds.value))
|
||||||
|
}
|
||||||
|
// Also saves when Windows ends the process (sign-out, shutdown) instead of the user quitting
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val hook = Thread { saveSession() }
|
||||||
|
Runtime.getRuntime().addShutdownHook(hook)
|
||||||
|
onDispose { }
|
||||||
|
}
|
||||||
|
|
||||||
// Full shutdown for the "Exit wIRC" button: disconnect every server (which also writes
|
// 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
|
// all scrollback to disk), then end the process. A bare /quit in a channel window only
|
||||||
// disconnects that window's server - see ConnectionManager.disconnect.
|
// disconnects that window's server - see ConnectionManager.disconnect.
|
||||||
val shutdownApp: () -> Unit = {
|
val shutdownApp: () -> Unit = {
|
||||||
|
saveSession()
|
||||||
ConnectionManager.disconnectAll()
|
ConnectionManager.disconnectAll()
|
||||||
exitApplication()
|
exitApplication()
|
||||||
}
|
}
|
||||||
@@ -85,33 +115,53 @@ 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
|
val mainTitle = if (mainUnread > 0) "wIRC - Main ($mainUnread new)" else "wIRC - Main"
|
||||||
// server-list window quits the whole app (and every channel window with it) - the
|
|
||||||
// same "closing the primary window ends the session" convention most desktop chat
|
ui.AppTray(
|
||||||
// apps without a tray icon follow.
|
// The unread count shows on the tray icon's tooltip too, so new server messages
|
||||||
|
// are visible even when the windows are hidden.
|
||||||
|
tooltip = if (mainUnread > 0) "wIRC ($mainUnread new)" else "wIRC",
|
||||||
|
onToggle = { if (anyHidden) showAll() else hideAll() },
|
||||||
|
onShow = showAll,
|
||||||
|
onShowMain = { mainHidden = false },
|
||||||
|
onHide = hideAll,
|
||||||
|
onExit = shutdownApp
|
||||||
|
)
|
||||||
|
|
||||||
|
// Closing the main server-list window quits the whole app (and every channel window
|
||||||
|
// with it) - the tray's Exit does the same, and also disconnects first.
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = ::exitApplication,
|
onCloseRequest = { saveSession(); exitApplication() },
|
||||||
// The unread count shows in the taskbar too, so new server messages are visible
|
visible = !mainHidden,
|
||||||
// even when the main window is behind other windows.
|
title = mainTitle,
|
||||||
title = if (mainUnread > 0) "wIRC - Main ($mainUnread new)" else "wIRC - Main",
|
|
||||||
icon = painterResource("icon.png"),
|
icon = painterResource("icon.png"),
|
||||||
state = mainWindowState
|
state = mainWindowState
|
||||||
) {
|
) {
|
||||||
|
TrayWindowBehavior(mainWindowState) { mainHidden = true }
|
||||||
|
androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) {
|
||||||
MaterialTheme(colorScheme = neutralDarkScheme) {
|
MaterialTheme(colorScheme = neutralDarkScheme) {
|
||||||
Surface {
|
Surface {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
repo = repo,
|
repo = repo,
|
||||||
colorScheme = neutralDarkScheme,
|
colorScheme = neutralDarkScheme,
|
||||||
displaySettings = mainDisplaySettings,
|
displaySettings = mainDisplaySettings,
|
||||||
|
// Started hidden: reconnect what was connected at quit, so the channel
|
||||||
|
// windows open without wIRC main having to be shown to click Connect.
|
||||||
|
autoConnectServerIds = if (startSession.mainHidden) startSession.connectedServerIds else emptySet(),
|
||||||
onOpenChannel = { serverId, channelName ->
|
onOpenChannel = { serverId, channelName ->
|
||||||
openBuffers = openBuffers + BufferKey(serverId, channelName)
|
openBuffers = openBuffers + BufferKey(serverId, channelName)
|
||||||
},
|
},
|
||||||
onExitApp = shutdownApp,
|
onExitApp = shutdownApp,
|
||||||
onUnreadChange = { mainUnread = it }
|
onUnreadChange = { mainUnread = it },
|
||||||
|
onFontChange = { id -> prefsRepo.setFontId(MAIN_BUFFER_KEY.storageId(), id) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
openBuffers.forEach { bufferKey ->
|
openBuffers.forEach { bufferKey ->
|
||||||
@@ -120,8 +170,11 @@ fun main() = application {
|
|||||||
bufferKey = bufferKey,
|
bufferKey = bufferKey,
|
||||||
prefsRepo = prefsRepo,
|
prefsRepo = prefsRepo,
|
||||||
colorScheme = neutralDarkScheme,
|
colorScheme = neutralDarkScheme,
|
||||||
|
visible = bufferKey !in hiddenBuffers,
|
||||||
|
onHide = { hiddenBuffers = hiddenBuffers + bufferKey },
|
||||||
onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey },
|
onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey },
|
||||||
onCloseSelf = { openBuffers = openBuffers - bufferKey },
|
// Also forgets it was hidden, so reopening it later shows it
|
||||||
|
onCloseSelf = { openBuffers = openBuffers - bufferKey; hiddenBuffers = hiddenBuffers - bufferKey },
|
||||||
// openBuffers is read when /quit runs, so every window open at that moment
|
// 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.
|
// gets its "Disconnected" line, even one that hasn't had a line yet.
|
||||||
onDisconnectServer = {
|
onDisconnectServer = {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.AtomicMoveNotSupportedException
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What wIRC looked like when it last quit: whether wIRC main was hidden to the tray, and
|
||||||
|
* which servers were connected. If main was hidden, the next start keeps it hidden and
|
||||||
|
* reconnects those servers so their channel windows open by themselves (see Main.kt).
|
||||||
|
*/
|
||||||
|
data class SessionState(
|
||||||
|
val mainHidden: Boolean = false,
|
||||||
|
val connectedServerIds: Set<String> = emptySet()
|
||||||
|
)
|
||||||
|
|
||||||
|
/** rootDir/session_state.json, read once at startup and written once at quit. */
|
||||||
|
class SessionStateRepository(rootDir: File) {
|
||||||
|
|
||||||
|
private val file = File(rootDir, "session_state.json")
|
||||||
|
@Volatile private var saved = false
|
||||||
|
|
||||||
|
fun load(): SessionState = try {
|
||||||
|
if (!file.exists()) SessionState() else {
|
||||||
|
val obj = JSONObject(file.readText())
|
||||||
|
val ids = obj.optJSONArray("connectedServerIds") ?: JSONArray()
|
||||||
|
SessionState(
|
||||||
|
mainHidden = obj.optBoolean("mainHidden", false),
|
||||||
|
connectedServerIds = (0 until ids.length()).map { ids.getString(it) }.toSet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SessionState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the first call counts: quitting can reach this from more than one place (the
|
||||||
|
* Exit/close handlers and the JVM shutdown hook), and a later call could come after the
|
||||||
|
* servers were already disconnected.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun saveOnce(state: SessionState) {
|
||||||
|
if (saved) return
|
||||||
|
saved = true
|
||||||
|
try {
|
||||||
|
val obj = JSONObject().apply {
|
||||||
|
put("mainHidden", state.mainHidden)
|
||||||
|
put("connectedServerIds", JSONArray(state.connectedServerIds.toList()))
|
||||||
|
}
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
// Temp file and move, same as WindowPrefsRepository, so a crash can't truncate it
|
||||||
|
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||||
|
tmp.writeText(obj.toString())
|
||||||
|
try {
|
||||||
|
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
|
||||||
|
} catch (e: AtomicMoveNotSupportedException) {
|
||||||
|
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Next start just opens normally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,18 @@ import java.nio.file.Files
|
|||||||
import java.nio.file.StandardCopyOption
|
import java.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)
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.platform.Font
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fonts offered in each window's settings. Stored in window_prefs.json by [id], so the ids
|
||||||
|
* must never change once shipped.
|
||||||
|
*
|
||||||
|
* Two kinds:
|
||||||
|
* - Windows fonts (Segoe UI, Tahoma, Tw Cen MT) are Microsoft-licensed, so they are NOT
|
||||||
|
* bundled. They are loaded straight out of the Windows font folders at runtime and only
|
||||||
|
* listed when the file is actually there (Tw Cen MT only exists if Office is installed).
|
||||||
|
* - Rounded fonts (Nunito, Quicksand, Varela Round) are SIL OFL licensed and bundled in
|
||||||
|
* src/main/resources/fonts with their license files.
|
||||||
|
*
|
||||||
|
* Anything missing or failing to load falls back to Monospace, so a font uninstalled after
|
||||||
|
* being picked can never crash a window.
|
||||||
|
*/
|
||||||
|
data class FontOption(val id: String, val label: String, val family: FontFamily)
|
||||||
|
|
||||||
|
object AppFonts {
|
||||||
|
const val DEFAULT_ID = "monospace"
|
||||||
|
|
||||||
|
private val fontDirs: List<File> = buildList {
|
||||||
|
add(File(System.getenv("WINDIR") ?: "C:\\Windows", "Fonts"))
|
||||||
|
System.getenv("LOCALAPPDATA")?.let { add(File(it, "Microsoft\\Windows\\Fonts")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive lookup across the system and per-user font folders. */
|
||||||
|
private fun findSystemFont(name: String): File? {
|
||||||
|
for (dir in fontDirs) {
|
||||||
|
val direct = File(dir, name)
|
||||||
|
if (direct.isFile) return direct
|
||||||
|
dir.listFiles()?.firstOrNull { it.name.equals(name, ignoreCase = true) }?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun systemFamily(regular: String, bold: String?): FontFamily? {
|
||||||
|
val reg = findSystemFont(regular) ?: return null
|
||||||
|
return try {
|
||||||
|
val fonts = mutableListOf(Font(reg, FontWeight.Normal))
|
||||||
|
bold?.let { findSystemFont(it) }?.let { fonts.add(Font(it, FontWeight.Bold)) }
|
||||||
|
FontFamily(fonts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bundledFamily(regular: String, bold: String?): FontFamily? = try {
|
||||||
|
val fonts = mutableListOf(Font("fonts/$regular", FontWeight.Normal))
|
||||||
|
bold?.let { fonts.add(Font("fonts/$it", FontWeight.Bold)) }
|
||||||
|
FontFamily(fonts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Built once at startup; the font folders don't change while wIRC is running. */
|
||||||
|
val options: List<FontOption> by lazy {
|
||||||
|
buildList {
|
||||||
|
add(FontOption(DEFAULT_ID, "Monospace", FontFamily.Monospace))
|
||||||
|
systemFamily("segoeui.ttf", "segoeuib.ttf")?.let { add(FontOption("segoe-ui", "Segoe UI", it)) }
|
||||||
|
systemFamily("tahoma.ttf", "tahomabd.ttf")?.let { add(FontOption("tahoma", "Tahoma", it)) }
|
||||||
|
systemFamily("TCM_____.TTF", "TCB_____.TTF")?.let { add(FontOption("tw-cen-mt", "Tw Cen MT", it)) }
|
||||||
|
bundledFamily("Nunito-Regular.ttf", "Nunito-Bold.ttf")?.let { add(FontOption("nunito", "Nunito (rounded)", it)) }
|
||||||
|
bundledFamily("Quicksand-Regular.ttf", "Quicksand-Bold.ttf")?.let { add(FontOption("quicksand", "Quicksand (rounded)", it)) }
|
||||||
|
bundledFamily("VarelaRound-Regular.ttf", null)?.let { add(FontOption("varela-round", "Varela Round (rounded)", it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun familyFor(id: String): FontFamily =
|
||||||
|
options.firstOrNull { it.id == id }?.family ?: FontFamily.Monospace
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The font list used in both the channel-window settings and wIRC main's Font dialog. Each
|
||||||
|
* name is drawn in its own font as a live preview; plain square-cornered rows, no dropdown.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FontPickerList(selectedId: String, textColor: Color, onPick: (String) -> Unit) {
|
||||||
|
val effectiveId = if (AppFonts.options.any { it.id == selectedId }) selectedId else AppFonts.DEFAULT_ID
|
||||||
|
Column {
|
||||||
|
AppFonts.options.forEach { option ->
|
||||||
|
Text(
|
||||||
|
text = option.label,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = option.family,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(if (option.id == effectiveId) Color.White.copy(alpha = 0.18f) else Color.Transparent)
|
||||||
|
.clickable { onPick(option.id) }
|
||||||
|
.padding(horizontal = 6.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.awt.ComposeWindow
|
||||||
|
import androidx.compose.ui.window.FrameWindowScope
|
||||||
|
import androidx.compose.ui.window.WindowState
|
||||||
|
import com.sun.jna.Native
|
||||||
|
import com.sun.jna.Pointer
|
||||||
|
import com.sun.jna.platform.win32.User32
|
||||||
|
import com.sun.jna.platform.win32.WinDef
|
||||||
|
import com.sun.jna.platform.win32.WinUser
|
||||||
|
import java.awt.Frame
|
||||||
|
import java.awt.MenuItem
|
||||||
|
import java.awt.PopupMenu
|
||||||
|
import java.awt.SystemTray
|
||||||
|
import java.awt.TrayIcon
|
||||||
|
import java.awt.event.MouseAdapter
|
||||||
|
import java.awt.event.MouseEvent
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wIRC's icon in the Windows notification area, there for as long as the app runs. wIRC
|
||||||
|
* windows never show on the taskbar (see [hideFromTaskbar]), so this is how they come back
|
||||||
|
* after being hidden. Left click shows every hidden window, or hides them all if none are
|
||||||
|
* hidden; right click opens Show wIRC / Show wIRC main / Hide wIRC / Exit.
|
||||||
|
* Plain AWT instead of Compose's Tray, because Compose's Tray only reports a double click.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppTray(
|
||||||
|
tooltip: String,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
onShow: () -> Unit,
|
||||||
|
onShowMain: () -> Unit,
|
||||||
|
onHide: () -> Unit,
|
||||||
|
onExit: () -> Unit
|
||||||
|
) {
|
||||||
|
val currentToggle by rememberUpdatedState(onToggle)
|
||||||
|
val currentShow by rememberUpdatedState(onShow)
|
||||||
|
val currentShowMain by rememberUpdatedState(onShowMain)
|
||||||
|
val currentHide by rememberUpdatedState(onHide)
|
||||||
|
val currentExit by rememberUpdatedState(onExit)
|
||||||
|
|
||||||
|
val trayIcon = remember {
|
||||||
|
if (!SystemTray.isSupported()) return@remember null
|
||||||
|
val image = Thread.currentThread().contextClassLoader.getResourceAsStream("icon.png")?.use { ImageIO.read(it) }
|
||||||
|
?: return@remember null
|
||||||
|
val menu = PopupMenu().apply {
|
||||||
|
add(MenuItem("Show wIRC").apply { addActionListener { currentShow() } })
|
||||||
|
add(MenuItem("Show wIRC main").apply { addActionListener { currentShowMain() } })
|
||||||
|
add(MenuItem("Hide wIRC").apply { addActionListener { currentHide() } })
|
||||||
|
addSeparator()
|
||||||
|
add(MenuItem("Exit").apply { addActionListener { currentExit() } })
|
||||||
|
}
|
||||||
|
TrayIcon(image, tooltip, menu).apply {
|
||||||
|
isImageAutoSize = true
|
||||||
|
addMouseListener(object : MouseAdapter() {
|
||||||
|
override fun mouseClicked(e: MouseEvent) {
|
||||||
|
if (e.button == MouseEvent.BUTTON1) currentToggle()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(trayIcon) {
|
||||||
|
trayIcon?.let { runCatching { SystemTray.getSystemTray().add(it) } }
|
||||||
|
onDispose { trayIcon?.let { SystemTray.getSystemTray().remove(it) } }
|
||||||
|
}
|
||||||
|
LaunchedEffect(trayIcon, tooltip) { trayIcon?.toolTip = tooltip }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called first thing inside every wIRC Window's content:
|
||||||
|
* - Keeps the window off the taskbar (see [hideFromTaskbar]).
|
||||||
|
* - Turns a minimize of this window into hiding just this window to the tray ([onHide]):
|
||||||
|
* with no taskbar button, a minimized window would have no way back. Win+D minimizes
|
||||||
|
* every window, so it still hides them all.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FrameWindowScope.TrayWindowBehavior(state: WindowState, onHide: () -> Unit) {
|
||||||
|
remember(window) { hideFromTaskbar(window) }
|
||||||
|
val currentHide by rememberUpdatedState(onHide)
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
snapshotFlow { state.isMinimized }.collect { minimized ->
|
||||||
|
if (minimized) {
|
||||||
|
currentHide()
|
||||||
|
// Hidden before it's un-minimized, so it doesn't flash back up on the way
|
||||||
|
window.isVisible = false
|
||||||
|
state.isMinimized = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows gives a taskbar button only to windows that have no owner, so every wIRC window is
|
||||||
|
* given one: a window that's never shown. Done natively because Window.Type.UTILITY (AWT's
|
||||||
|
* own way off the taskbar) can only be set before Compose has built the window, and on a
|
||||||
|
* framed window like wIRC main it also swaps in a small tool-window title bar with no
|
||||||
|
* minimize button. Runs while the window is built but not yet shown, which is when the
|
||||||
|
* taskbar decides whether it gets a button.
|
||||||
|
*/
|
||||||
|
fun hideFromTaskbar(window: ComposeWindow) {
|
||||||
|
try {
|
||||||
|
val hwnd = WinDef.HWND(Pointer(window.windowHandle))
|
||||||
|
val user32 = User32.INSTANCE
|
||||||
|
user32.SetWindowLongPtr(hwnd, GWLP_HWNDPARENT, TaskbarOwner.hwnd.pointer)
|
||||||
|
val exStyle = user32.GetWindowLong(hwnd, WinUser.GWL_EXSTYLE)
|
||||||
|
if (exStyle and WS_EX_APPWINDOW != 0) {
|
||||||
|
user32.SetWindowLong(hwnd, WinUser.GWL_EXSTYLE, exStyle and WS_EX_APPWINDOW.inv())
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
// Not on Windows, or no native access: the window just keeps its taskbar button
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val GWLP_HWNDPARENT = -8
|
||||||
|
private const val WS_EX_APPWINDOW = 0x00040000
|
||||||
|
|
||||||
|
private object TaskbarOwner {
|
||||||
|
val hwnd: WinDef.HWND by lazy {
|
||||||
|
// Made displayable (so it has a native window) but never shown
|
||||||
|
val frame = Frame().apply { addNotify() }
|
||||||
|
WinDef.HWND(Native.getComponentPointer(frame))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@file:OptIn(androidx.compose.ui.text.ExperimentalTextApi::class)
|
||||||
|
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
@@ -61,6 +63,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
|
||||||
@@ -78,11 +81,12 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.DialogWindow
|
import androidx.compose.ui.window.DialogWindow
|
||||||
import androidx.compose.ui.window.WindowScope
|
import androidx.compose.ui.window.WindowScope
|
||||||
import androidx.compose.ui.window.WindowState
|
|
||||||
import androidx.compose.ui.window.rememberDialogState
|
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 +106,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
|
||||||
@@ -180,7 +185,7 @@ private fun copyToClipboardSafely(scope: CoroutineScope, text: String) {
|
|||||||
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun WindowScope.ChannelScreen(
|
fun WindowScope.ChannelScreen(
|
||||||
windowState: WindowState,
|
onMinimize: () -> Unit,
|
||||||
onCloseWindow: () -> Unit,
|
onCloseWindow: () -> Unit,
|
||||||
channelName: String,
|
channelName: String,
|
||||||
buffer: ChannelBuffer?,
|
buffer: ChannelBuffer?,
|
||||||
@@ -195,6 +200,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 +376,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))
|
||||||
@@ -378,7 +384,7 @@ fun WindowScope.ChannelScreen(
|
|||||||
FadingTitleBarButton(onClick = { showSettings = !showSettings }) {
|
FadingTitleBarButton(onClick = { showSettings = !showSettings }) {
|
||||||
Icon(Icons.Default.Settings, contentDescription = "Window settings", tint = textColor, modifier = Modifier.size(16.dp))
|
Icon(Icons.Default.Settings, contentDescription = "Window settings", tint = textColor, modifier = Modifier.size(16.dp))
|
||||||
}
|
}
|
||||||
FadingTitleBarButton(onClick = { windowState.isMinimized = true }) {
|
FadingTitleBarButton(onClick = onMinimize) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(width = 10.dp, height = 2.dp)
|
.size(width = 10.dp, height = 2.dp)
|
||||||
@@ -416,10 +422,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 +462,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 +500,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
|
||||||
),
|
),
|
||||||
@@ -809,7 +818,7 @@ fun MessageScrollback(
|
|||||||
.first { item ->
|
.first { item ->
|
||||||
item == null || (item.index < capturedList.size && item.key == capturedList[item.index].seq)
|
item == null || (item.index < capturedList.size && item.key == capturedList[item.index].seq)
|
||||||
}
|
}
|
||||||
listState.scrollToItem(0)
|
if (stickToBottom) listState.scrollToItem(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,14 +849,30 @@ 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) {
|
// The buffer text style gets extra vertical air on top of the font's own natural
|
||||||
textMeasurer.measure(
|
// line height (every bundled font has different metrics, so a fixed multiplier
|
||||||
text = "Mg",
|
// would loosen some fonts and tighten others). The same style is used to measure
|
||||||
style = androidx.compose.ui.text.TextStyle(
|
// the line height below AND to draw every row, so the whole-line snapping stays
|
||||||
fontFamily = FontFamily.Monospace,
|
// exactly in step with what is on screen. LineHeightStyle with Trim.None and a
|
||||||
fontSize = displaySettings.fontSize.sp
|
// centered alignment makes each line exactly lineHeight tall, with the extra
|
||||||
|
// space split above and below the text.
|
||||||
|
val bufferTextStyle = remember(displaySettings.fontSize, displaySettings.fontId, density) {
|
||||||
|
val base = androidx.compose.ui.text.TextStyle(
|
||||||
|
fontFamily = AppFonts.familyFor(displaySettings.fontId),
|
||||||
|
fontSize = displaySettings.fontSize.sp
|
||||||
|
)
|
||||||
|
val naturalPx = textMeasurer.measure(text = "Mg", style = base).size.height
|
||||||
|
val spacedLine = with(density) { (naturalPx + (displaySettings.fontSize * LINE_SPACING_EXTRA).sp.toPx()).toSp() }
|
||||||
|
base.copy(
|
||||||
|
lineHeight = spacedLine,
|
||||||
|
lineHeightStyle = androidx.compose.ui.text.style.LineHeightStyle(
|
||||||
|
alignment = androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center,
|
||||||
|
trim = androidx.compose.ui.text.style.LineHeightStyle.Trim.None
|
||||||
)
|
)
|
||||||
).size.height
|
)
|
||||||
|
}
|
||||||
|
val lineHeightPx = remember(bufferTextStyle, density) {
|
||||||
|
textMeasurer.measure(text = "Mg", style = bufferTextStyle).size.height
|
||||||
}
|
}
|
||||||
val lineHeightDp = with(density) { lineHeightPx.toDp() }
|
val lineHeightDp = with(density) { lineHeightPx.toDp() }
|
||||||
// Ruled out (see task: newest-message-missing investigation) as the
|
// Ruled out (see task: newest-message-missing investigation) as the
|
||||||
@@ -857,10 +882,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
|
||||||
@@ -874,10 +903,7 @@ fun MessageScrollback(
|
|||||||
// offset correction through stickToBottom's own effect instead of a
|
// offset correction through stickToBottom's own effect instead of a
|
||||||
// second independent one.
|
// second independent one.
|
||||||
|
|
||||||
val textStyle = androidx.compose.ui.text.TextStyle(
|
val textStyle = bufferTextStyle
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = displaySettings.fontSize.sp
|
|
||||||
)
|
|
||||||
val selection = selectionState.selection
|
val selection = selectionState.selection
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -897,6 +923,31 @@ fun MessageScrollback(
|
|||||||
.height(snappedHeight)
|
.height(snappedHeight)
|
||||||
.onGloballyPositioned { containerCoords[0] = it }
|
.onGloballyPositioned { containerCoords[0] = it }
|
||||||
.pointerHoverIcon(PointerIcon.Text)
|
.pointerHoverIcon(PointerIcon.Text)
|
||||||
|
// Wheel-up means the user is reading backscroll: stop sticking to the bottom RIGHT NOW,
|
||||||
|
// on the raw wheel event. The scroll-state effect up top can miss a wheel scroll that starts
|
||||||
|
// and finishes inside one frame (isScrollInProgress is never seen as true), which left
|
||||||
|
// stickToBottom set and let the next incoming line yank the buffer back down.
|
||||||
|
// Wheel-down re-checks the resting position shortly after and re-enables sticking if the
|
||||||
|
// user is back at the bottom.
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
if (event.type == PointerEventType.Scroll) {
|
||||||
|
val dy = event.changes.firstOrNull()?.scrollDelta?.y ?: 0f
|
||||||
|
if (dy < 0f) {
|
||||||
|
stickToBottom = false
|
||||||
|
} else if (dy > 0f) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
delay(200)
|
||||||
|
stickToBottom = listState.firstVisibleItemIndex == 0 &&
|
||||||
|
listState.firstVisibleItemScrollOffset <= stickToBottomThresholdPx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Remembers the row under a right-click for the menu's "Copy line". Watches the
|
// Remembers the row under a right-click for the menu's "Copy line". Watches the
|
||||||
// Initial pass, because ContextMenuArea takes the right-press there, before
|
// Initial pass, because ContextMenuArea takes the right-press there, before
|
||||||
// the handler below would see it.
|
// the handler below would see it.
|
||||||
@@ -1029,7 +1080,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 +1099,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,14 +1128,19 @@ fun MessageScrollback(
|
|||||||
}
|
}
|
||||||
if (displaySettings.showImagePreviews) {
|
if (displaySettings.showImagePreviews) {
|
||||||
extractImageUrls(msg.rawText).forEach { url ->
|
extractImageUrls(msg.rawText).forEach { url ->
|
||||||
UrlImagePreview(
|
// A thumbnail can be any height, so its slot is rounded UP to whole lines
|
||||||
url = url,
|
// (never cropping the picture); otherwise every line above it in the
|
||||||
modifier = Modifier
|
// window would sit a fraction of a line off and show cut in half at the top.
|
||||||
.padding(start = 8.dp, top = 2.dp, bottom = 4.dp)
|
Box(modifier = Modifier.snapHeightToLines(lineHeightPx, roundUp = true)) {
|
||||||
.heightIn(max = 200.dp)
|
UrlImagePreview(
|
||||||
.clip(RoundedCornerShape(4.dp)),
|
url = url,
|
||||||
onClick = { openUrlInBrowser(url) }
|
modifier = Modifier
|
||||||
)
|
.padding(start = 8.dp, top = 2.dp, bottom = 4.dp)
|
||||||
|
.heightIn(max = 200.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp)),
|
||||||
|
onClick = { openUrlInBrowser(url) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1092,6 +1154,25 @@ fun MessageScrollback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extra vertical space added to every buffer line, as a fraction of the font size, on top of
|
||||||
|
// the font's natural line height. 0.30 is a modest loosening; raise it for more air between
|
||||||
|
// lines (0.45 is roomy), lower it for less. Applies to every font in the font list.
|
||||||
|
private const val LINE_SPACING_EXTRA = 0.30f
|
||||||
|
|
||||||
|
// Reports this element's height as a whole number of text lines ([linePx] each): rounded up
|
||||||
|
// for image slots (the picture is never cut), to the nearest line for text rows (a wrapped
|
||||||
|
// message can measure a pixel or two off an exact multiple). Content is drawn from the top.
|
||||||
|
private fun Modifier.snapHeightToLines(linePx: Int, roundUp: Boolean): Modifier = layout { measurable, constraints ->
|
||||||
|
val placeable = measurable.measure(constraints.copy(minHeight = 0))
|
||||||
|
val h = placeable.height
|
||||||
|
val snapped = when {
|
||||||
|
linePx <= 0 || h == 0 -> h
|
||||||
|
roundUp -> ((h + linePx - 1) / linePx) * linePx
|
||||||
|
else -> (((h + linePx / 2) / linePx).coerceAtLeast(1)) * linePx
|
||||||
|
}.coerceIn(constraints.minHeight, constraints.maxHeight.coerceAtLeast(constraints.minHeight))
|
||||||
|
layout(placeable.width, snapped) { placeable.place(0, 0) }
|
||||||
|
}
|
||||||
|
|
||||||
// Which of a row's two texts a TextPos is in: the timestamp/nick prefix, or the body.
|
// 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 +1478,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 +1487,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 +1517,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(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -45,6 +46,8 @@ fun ChannelWindow(
|
|||||||
bufferKey: BufferKey,
|
bufferKey: BufferKey,
|
||||||
prefsRepo: WindowPrefsRepository,
|
prefsRepo: WindowPrefsRepository,
|
||||||
colorScheme: ColorScheme,
|
colorScheme: ColorScheme,
|
||||||
|
visible: Boolean,
|
||||||
|
onHide: () -> Unit,
|
||||||
onOpenBuffer: (BufferKey) -> Unit,
|
onOpenBuffer: (BufferKey) -> Unit,
|
||||||
onCloseSelf: () -> Unit,
|
onCloseSelf: () -> Unit,
|
||||||
onDisconnectServer: () -> Unit
|
onDisconnectServer: () -> Unit
|
||||||
@@ -74,14 +77,20 @@ 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
|
||||||
// supplies its own title bar with minimize/close/drag/resize to make up for what the
|
// supplies its own title bar with minimize/close/drag/resize to make up for what the
|
||||||
// OS chrome would otherwise provide. No Surface wrapper here (M3 Surface paints an
|
// OS chrome would otherwise provide. No Surface wrapper here (M3 Surface paints an
|
||||||
// opaque background) - ChannelScreen already paints its own alpha-aware background.
|
// opaque background) - ChannelScreen already paints its own alpha-aware background.
|
||||||
|
// Hidden to the tray when [visible] is false - the window and its composition stay
|
||||||
|
// alive, so its buffer keeps filling while hidden.
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = onCloseSelf,
|
onCloseRequest = onCloseSelf,
|
||||||
|
visible = visible,
|
||||||
title = windowTitle,
|
title = windowTitle,
|
||||||
icon = painterResource("icon.png"),
|
icon = painterResource("icon.png"),
|
||||||
state = windowState,
|
state = windowState,
|
||||||
@@ -89,9 +98,11 @@ fun ChannelWindow(
|
|||||||
transparent = true,
|
transparent = true,
|
||||||
resizable = true
|
resizable = true
|
||||||
) {
|
) {
|
||||||
|
TrayWindowBehavior(windowState, onHide)
|
||||||
|
CompositionLocalProvider(LocalLineGap provides lineGap) {
|
||||||
MaterialTheme(colorScheme = colorScheme) {
|
MaterialTheme(colorScheme = colorScheme) {
|
||||||
ChannelScreen(
|
ChannelScreen(
|
||||||
windowState = windowState,
|
onMinimize = onHide,
|
||||||
onCloseWindow = onCloseSelf,
|
onCloseWindow = onCloseSelf,
|
||||||
channelName = windowTitle,
|
channelName = windowTitle,
|
||||||
buffer = buffer,
|
buffer = buffer,
|
||||||
@@ -181,6 +192,9 @@ fun ChannelWindow(
|
|||||||
onFontSizeChange = { size ->
|
onFontSizeChange = { size ->
|
||||||
scope.launch(Dispatchers.IO) { prefsRepo.setFontSize(bufferKey.storageId(), size) }
|
scope.launch(Dispatchers.IO) { prefsRepo.setFontSize(bufferKey.storageId(), size) }
|
||||||
},
|
},
|
||||||
|
onFontChange = { id ->
|
||||||
|
scope.launch(Dispatchers.IO) { prefsRepo.setFontId(bufferKey.storageId(), id) }
|
||||||
|
},
|
||||||
onShowUserListChange = { show ->
|
onShowUserListChange = { show ->
|
||||||
scope.launch(Dispatchers.IO) { prefsRepo.setShowUserList(bufferKey.storageId(), show) }
|
scope.launch(Dispatchers.IO) { prefsRepo.setShowUserList(bufferKey.storageId(), show) }
|
||||||
},
|
},
|
||||||
@@ -195,5 +209,6 @@ fun ChannelWindow(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,13 @@ fun MainScreen(
|
|||||||
repo: ServerRepository,
|
repo: ServerRepository,
|
||||||
colorScheme: ColorScheme,
|
colorScheme: ColorScheme,
|
||||||
displaySettings: WindowDisplaySettings,
|
displaySettings: WindowDisplaySettings,
|
||||||
|
autoConnectServerIds: Set<String>,
|
||||||
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) }
|
||||||
@@ -55,13 +58,38 @@ fun MainScreen(
|
|||||||
var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
|
var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
fun connectServer(server: ServerConfig) {
|
||||||
|
// Also rejoins the channels this server was in before a /quit - their
|
||||||
|
// windows are still open, and opening one that already is is a no-op.
|
||||||
|
ConnectionManager.connect(server)
|
||||||
|
if (server.autoJoinChannels.isNotEmpty()) {
|
||||||
|
// Auto-join sends JOIN correctly on connect (see IrcConnection),
|
||||||
|
// but nothing else opens a window for it like the manual
|
||||||
|
// "Join + Open" flow does - so give the server a moment to
|
||||||
|
// actually join us before opening windows for them.
|
||||||
|
coroutineScope.launch {
|
||||||
|
delay(500)
|
||||||
|
server.autoJoinChannels.forEach { ch ->
|
||||||
|
val current = openedChannels[server.id] ?: emptyList()
|
||||||
|
if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch))
|
||||||
|
onOpenChannel(server.id, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Servers to connect at startup, as if Connect had been clicked (see Main.kt)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
servers.filter { it.id in autoConnectServerIds }.forEach { connectServer(it) }
|
||||||
|
}
|
||||||
|
|
||||||
val buffers by ConnectionManager.buffers.collectAsState()
|
val buffers by ConnectionManager.buffers.collectAsState()
|
||||||
val mainMessages by remember { derivedStateOf { buffers[MAIN_BUFFER_KEY]?.messages ?: emptyList() } }
|
val mainMessages by remember { derivedStateOf { buffers[MAIN_BUFFER_KEY]?.messages ?: emptyList() } }
|
||||||
val textColor = mircPalette[displaySettings.textColorMircIndex]
|
val textColor = mircPalette[displaySettings.textColorMircIndex]
|
||||||
|
|
||||||
// Unread indicator: lines that arrived in wIRC main while this window didn't have focus
|
// Unread indicator: lines that arrived in wIRC main while this window didn't have focus
|
||||||
// (e.g. while typing in a channel window). Shown next to the "Server messages" label and,
|
// (e.g. while typing in a channel window). Shown next to the "Server messages" label and,
|
||||||
// via onUnreadChange, in the window title / taskbar. Cleared as soon as the window is
|
// via onUnreadChange, in the window title and the tray tooltip. Cleared as soon as the window is
|
||||||
// focused. Compared by timestamp rather than count so history loaded at startup never
|
// focused. Compared by timestamp rather than count so history loaded at startup never
|
||||||
// counts as new.
|
// counts as new.
|
||||||
val windowFocused = LocalWindowInfo.current.isWindowFocused
|
val windowFocused = LocalWindowInfo.current.isWindowFocused
|
||||||
@@ -87,6 +115,12 @@ fun MainScreen(
|
|||||||
Text("+ Add Server")
|
Text("+ Add Server")
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
|
// 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
|
// Disconnects every server and terminates the process. (A bare "/quit" in a
|
||||||
// channel window only disconnects that window's server.)
|
// channel window only disconnects that window's server.)
|
||||||
TextButton(onClick = onExitApp) {
|
TextButton(onClick = onExitApp) {
|
||||||
@@ -108,25 +142,7 @@ fun MainScreen(
|
|||||||
channelInput = channelInputs[server.id] ?: "#",
|
channelInput = channelInputs[server.id] ?: "#",
|
||||||
openedChannels = openedChannels[server.id] ?: emptyList(),
|
openedChannels = openedChannels[server.id] ?: emptyList(),
|
||||||
onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) },
|
onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) },
|
||||||
onConnect = {
|
onConnect = { connectServer(server) },
|
||||||
// Also rejoins the channels this server was in before a /quit - their
|
|
||||||
// windows are still open, and opening one that already is is a no-op.
|
|
||||||
ConnectionManager.connect(server)
|
|
||||||
if (server.autoJoinChannels.isNotEmpty()) {
|
|
||||||
// Auto-join sends JOIN correctly on connect (see IrcConnection),
|
|
||||||
// but nothing else opens a window for it like the manual
|
|
||||||
// "Join + Open" flow does - so give the server a moment to
|
|
||||||
// actually join us before opening windows for them.
|
|
||||||
coroutineScope.launch {
|
|
||||||
delay(500)
|
|
||||||
server.autoJoinChannels.forEach { ch ->
|
|
||||||
val current = openedChannels[server.id] ?: emptyList()
|
|
||||||
if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch))
|
|
||||||
onOpenChannel(server.id, ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onEdit = { editingServer = server; showAddDialog = true },
|
onEdit = { editingServer = server; showAddDialog = true },
|
||||||
onDelete = {
|
onDelete = {
|
||||||
repo.remove(server.id)
|
repo.remove(server.id)
|
||||||
@@ -187,6 +203,25 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showFontDialog) {
|
||||||
|
DialogWindow(
|
||||||
|
onCloseRequest = { showFontDialog = false },
|
||||||
|
title = "wIRC main font",
|
||||||
|
state = rememberDialogState(size = DpSize(300.dp, 360.dp)),
|
||||||
|
resizable = true
|
||||||
|
) {
|
||||||
|
MaterialTheme(colorScheme = colorScheme) {
|
||||||
|
Surface {
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(8.dp).verticalScroll(rememberScrollState())) {
|
||||||
|
Text("Server messages font:", color = Color.White, style = MaterialTheme.typography.bodySmall)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
FontPickerList(displaySettings.fontId, Color.White, onFontChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showAddDialog) {
|
if (showAddDialog) {
|
||||||
ServerEditDialog(
|
ServerEditDialog(
|
||||||
existing = editingServer,
|
existing = editingServer,
|
||||||
|
|||||||
@@ -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