14 Commits
Author SHA1 Message Date
weishauptandClaude Sonnet 5.5 d58b5772df Bump to 0.1.17
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 14:12:59 -04:00
weishauptandClaude Sonnet 5.5 fff75700ac Fix scrollback snapping back to bottom after scrolling up
Wheel-up now clears stickToBottom immediately on the raw scroll event, wheel-down re-checks the resting position, and the auto-scroll effect re-checks stickToBottom right before scrollToItem(0). The scroll-state effect could miss a wheel scroll that began and ended within one frame, so the next incoming line pulled the buffer back to the bottom.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW57ZvxRmxcDF7AFACLSoV
2026-10-29 18:09:13 +00:00
weishauptandClaude Sonnet 5.5 ed5d9a9c78 Fix line spacing compile error (TextUnit addition), bump to 0.1.16
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 22:43:19 -04:00
weishauptandClaude Sonnet 5.5 03964ca313 Loosen buffer line spacing for all fonts
Add extra vertical space to every buffer line (LINE_SPACING_EXTRA, 30% of font size) on top of the font natural line height. The same style measures and draws rows so whole-line snapping stays in step.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW57ZwRSygeowNBguQEUUB
2026-10-29 02:30:39 +00:00
weishauptandClaude Opus 5.5 78bd235a48 Minimize hides only that window, Show wIRC main tray item, remember hidden main, bump to 0.1.15
- Minimizing a window hides only that window to the tray. The other windows stay
  on the desktop. Win+D still hides everything.
- Tray left click shows every hidden window at its old position, or hides them
  all if none are hidden.
- The tray menu has a new Show wIRC main item that brings back just wIRC main.
- If wIRC main was hidden when wIRC quit, it starts hidden next time and the
  servers that were connected reconnect by themselves, so their channel windows
  open without wIRC main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:31:29 -04:00
weishauptandClaude Opus 5.5 24f54574ec Live in the system tray instead of the taskbar, bump to 0.1.14
- A tray icon (the app icon) is there for as long as wIRC runs. Left click hides
  or shows every wIRC window; right click has Show wIRC, Hide wIRC and Exit. Exit
  disconnects every server and quits.
- No wIRC window has a taskbar button. Each window is given a hidden owner window
  through JNA, because Window.Type.UTILITY can't be set after Compose builds the
  window and would take wIRC main's minimize button away.
- Minimizing any window hides all of them to the tray. Showing them again puts
  each one back where it was. Hidden windows stay connected and keep filling their
  buffers. Closing a window with X works the same as before.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:16:02 -04:00
weishauptandClaude Opus 5.5 efad80e141 Add font picker (Segoe UI, Tahoma, Tw Cen MT, Nunito, Quicksand, Varela Round), bump to 0.1.10
Each channel window's settings panel gets a Font list, and wIRC main gets a Font
button that opens the same list for the server messages box. Every name is drawn
in its own font as a preview. The choice is saved per window in window_prefs.json
(fontId) and applies to the scrollback, user list, input box and title.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:07:52 -04:00
weishauptandClaude Opus 5.5 807903fe0f Add wIRC main scrollback, route services to it, remember window bounds, bump to 0.1.7
wIRC main scrollback
- The main window now has a "Server messages" scrollback using the same
  rendering and stick-to-bottom code as channel windows (extracted from
  ChannelScreen into a shared MessageScrollback composable).
- MOTD, numerics, errors, connection status, user modes, INVITE, ERROR,
  non-ACTION CTCP requests and notices/messages from services
  (NickServ, ChanServ, *Serv, Global, ...) and the server go there. Its
  history persists like any other buffer (history/wirc-main__main.json).
- Unread indicator: lines arriving while the main window is unfocused
  show as "N new" next to the label and in the window title/taskbar.

Private messages and /whois
- A PM or notice from a real user opens its query window, and reopens it
  if it was closed. Services and the server no longer open windows.
- /whois <nick> sends WHOIS and shows the formatted reply in the window
  it was typed in, or in wIRC main if that window has since closed.
- /me now sends a proper CTCP ACTION (the \x01 wrapper was missing).

Window position and size
- Main, channel and PM windows remember position and size in
  window_prefs.json under the existing "<serverId>__<name>" key (main
  uses "wirc-main__main"). The new windowX/windowY/windowWidth/
  windowHeight fields are optional, so older files still load.
- A saved position is only used if the window's title strip is on a
  connected monitor; otherwise, and for windows with no saved position,
  windows cascade from the primary screen's top-left with a 32dp offset.
- window_prefs.json is now written via a temp file + atomic move.

Cleanup
- Remove the TEMP DEBUG aliasDebug tracing, ServerRepository.upsert
  printlns and connmgr-debug printlns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:08:38 -04:00
weishauptandClaude Opus 5.5 242d7a7c42 Fade title-bar buttons in on hover, remove temp debug logger, bump to 0.1.6
- Nicklist, settings, minimize and close buttons in the channel window
  title bar now rest at alpha 0 and fade to full opacity on hover
  (graphicsLayer alpha, so they stay laid out and clickable). Resting
  alpha, hover alpha and fade duration are constants at the top of
  ChannelScreen.kt. Channel name and window dragging are unchanged.
- Remove the unused temporary ircUiDebug helper now that the scrollback
  fix is confirmed.
- Bump version to 0.1.6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:07:02 -04:00
24 changed files with 2662 additions and 564 deletions
+5 -3
View File
@@ -1,4 +1,4 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins {
kotlin("jvm") version "1.9.24"
@@ -6,7 +6,7 @@ plugins {
}
group = "com.tpuahsiew.wirc"
version = "0.1.5"
version = "0.1.17"
// No project-level repositories{} block here: settings.gradle.kts sets
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
@@ -22,6 +22,8 @@ dependencies {
implementation(compose.materialIconsExtended)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1")
implementation("org.json:json:20240303")
// Native Windows calls that keep wIRC's windows off the taskbar (see AppTray.kt)
implementation("net.java.dev.jna:jna-platform:5.14.0")
}
kotlin {
@@ -38,7 +40,7 @@ compose.desktop {
appResourcesRootDir.set(project.file("installerResources"))
targetFormats(TargetFormat.Msi, TargetFormat.Exe)
packageName = "wIRC"
packageVersion = "0.1.5"
packageVersion = "0.1.17"
windows {
menuGroup = "wIRC"
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
+90 -16
View File
@@ -1,7 +1,9 @@
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
@@ -9,16 +11,23 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import data.AliasRepository
import data.BufferKey
import data.MAIN_BUFFER_KEY
import data.ServerRepository
import data.SessionState
import data.SessionStateRepository
import data.WindowPrefsRepository
import data.storageId
import irc.ConnectionManager
import ui.ChannelWindow
import ui.MainScreen
import ui.TrayWindowBehavior
import ui.rememberPersistentWindowState
import java.io.File
// Material 3's darkColorScheme() defaults to a purple accent; this keeps everything
@@ -46,16 +55,43 @@ fun main() = application {
// Loaded once here (not per-window) so the alias map is truly global - every
// ChannelWindow's command router reads from this same AliasRepository singleton.
AliasRepository.init()
// Ask Windows for the desktop icon grid now, so the first window drag can already snap to it
ui.DesktopGrid.refreshAsync()
val repo = remember { ServerRepository(appRoot) }
val prefsRepo = remember { WindowPrefsRepository(appRoot) }
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
var mainUnread by remember { mutableStateOf(0) }
// Full shutdown: same steps /quit (bare, no args) already runs from a channel window -
// disconnect every server, then end the process. Shared here so the "Exit wIRC" button
// and every channel window's /quit reuse the exact same logic instead of duplicating it.
// 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
// all scrollback to disk), then end the process. A bare /quit in a channel window only
// disconnects that window's server - see ConnectionManager.disconnect.
val shutdownApp: () -> Unit = {
saveSession()
ConnectionManager.disconnectAll()
exitApplication()
}
@@ -65,34 +101,67 @@ fun main() = application {
// ConnectionManager's sockets just live for as long as this process runs, and this
// listener (which the Android foreground service used to own) can live directly at
// the application level instead.
//
// Fires for every private message to or from a real user, so a query window the user
// closed reopens when that person messages again. Adding a key that's already open is
// a no-op (openBuffers is a set).
LaunchedEffect(Unit) {
ConnectionManager.newQueryBuffers.collect { key ->
ConnectionManager.queryWindowRequests.collect { key ->
openBuffers = openBuffers + key
}
}
// There's no persistent tray/notification surface on this build, so closing the main
// server-list window quits the whole app (and every channel window with it) - the
// same "closing the primary window ends the session" convention most desktop chat
// apps without a tray icon follow.
// Restored from / saved to window_prefs.json under MAIN_BUFFER_KEY's storage id
// ("wirc-main__main"), same as every channel and PM window - see WindowPlacement.kt.
val mainWindowState = rememberPersistentWindowState(prefsRepo, MAIN_BUFFER_KEY.storageId(), DpSize(800.dp, 700.dp))
val mainDisplaySettings by prefsRepo.settingsFor(MAIN_BUFFER_KEY.storageId()).collectAsState()
// Snap to the desktop grid after a move, and to whole lines after a resize
val mainLineGap = ui.rememberLineGap()
ui.WindowSnapping(mainWindowState, mainLineGap, osFrame = true)
val mainTitle = if (mainUnread > 0) "wIRC - Main ($mainUnread new)" else "wIRC - Main"
ui.AppTray(
// The unread count shows on the tray icon's tooltip too, so new server messages
// are visible even when the windows are hidden.
tooltip = if (mainUnread > 0) "wIRC ($mainUnread new)" else "wIRC",
onToggle = { if (anyHidden) showAll() else hideAll() },
onShow = showAll,
onShowMain = { mainHidden = false },
onHide = hideAll,
onExit = shutdownApp
)
// Closing the main server-list window quits the whole app (and every channel window
// with it) - the tray's Exit does the same, and also disconnects first.
Window(
onCloseRequest = ::exitApplication,
title = "wIRC - Main",
onCloseRequest = { saveSession(); exitApplication() },
visible = !mainHidden,
title = mainTitle,
icon = painterResource("icon.png"),
state = rememberWindowState()
state = mainWindowState
) {
TrayWindowBehavior(mainWindowState) { mainHidden = true }
androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) {
MaterialTheme(colorScheme = neutralDarkScheme) {
Surface {
MainScreen(
repo = repo,
colorScheme = neutralDarkScheme,
displaySettings = mainDisplaySettings,
// Started hidden: reconnect what was connected at quit, so the channel
// windows open without wIRC main having to be shown to click Connect.
autoConnectServerIds = if (startSession.mainHidden) startSession.connectedServerIds else emptySet(),
onOpenChannel = { serverId, channelName ->
openBuffers = openBuffers + BufferKey(serverId, channelName)
},
onExitApp = shutdownApp
onExitApp = shutdownApp,
onUnreadChange = { mainUnread = it },
onFontChange = { id -> prefsRepo.setFontId(MAIN_BUFFER_KEY.storageId(), id) }
)
}
}
}
}
openBuffers.forEach { bufferKey ->
@@ -101,10 +170,15 @@ fun main() = application {
bufferKey = bufferKey,
prefsRepo = prefsRepo,
colorScheme = neutralDarkScheme,
visible = bufferKey !in hiddenBuffers,
onHide = { hiddenBuffers = hiddenBuffers + bufferKey },
onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey },
onCloseSelf = { openBuffers = openBuffers - bufferKey },
onQuitApp = {
exitApplication()
// Also forgets it was hidden, so reopening it later shows it
onCloseSelf = { openBuffers = openBuffers - bufferKey; hiddenBuffers = hiddenBuffers - bufferKey },
// openBuffers is read when /quit runs, so every window open at that moment
// gets its "Disconnected" line, even one that hasn't had a line yet.
onDisconnectServer = {
ConnectionManager.disconnect(bufferKey.serverId, openBuffers)
}
)
}
+10 -18
View File
@@ -5,13 +5,6 @@ import java.io.File
import java.net.URI
import java.net.URLEncoder
// TEMP DEBUG (see task: alias multi-word arguments investigation) - remove once confirmed
// fixed. Traces the full alias pipeline: raw input -> matched alias -> substituted target
// -> the actual Desktop API call, so a silent failure anywhere in that chain is visible.
private fun aliasDebug(msg: String) {
println("[alias-debug] $msg")
}
// Name reserved for the app-level /reload command (ChannelWindow.kt handles it directly,
// before alias lookup even runs) - kept here too so a line in aliases.txt can never define
// an alias that would shadow it.
@@ -57,6 +50,12 @@ object AliasRepository {
return aliases.size
}
/** True if [command] (without the leading '/') is an alias - those run locally, no server needed. */
fun hasAlias(command: String): Boolean {
val key = command.lowercase()
return key != RESERVED_RELOAD && key in aliases
}
/**
* Returns true if [command] (without the leading '/') matched a known alias and its
* action was run - callers should treat that as "handled, don't fall through to IRC
@@ -64,15 +63,9 @@ object AliasRepository {
* "reload" name, which this repository never loads as an alias).
*/
fun tryRunAlias(command: String, args: String): Boolean {
aliasDebug("tryRunAlias: command=\"$command\" args=\"$args\"")
val key = command.lowercase()
if (key == RESERVED_RELOAD) return false
val action = aliases[key]
if (action == null) {
aliasDebug("no alias matched for \"$key\"")
return false
}
aliasDebug("matched \"$key\" -> \"$action\"")
val action = aliases[key] ?: return false
runAction(action, args)
return true
}
@@ -94,13 +87,12 @@ object AliasRepository {
// literal, not a URL delimiter.
val substitutedArg = if (type == "url") encodeArgForUrl(args) else args
val target = if (rawTarget.contains("{arg}")) rawTarget.replace("{arg}", substitutedArg) else rawTarget
aliasDebug("type=\"$type\" rawTarget=\"$rawTarget\" substitutedArg=\"$substitutedArg\" -> target=\"$target\"")
if (target.isBlank()) return
try {
when (type) {
"path" -> { aliasDebug("Desktop.open(File(\"$target\"))"); Desktop.getDesktop().open(File(target)) }
"url" -> { aliasDebug("Desktop.browse(URI(\"$target\"))"); Desktop.getDesktop().browse(URI(target)) }
"path" -> Desktop.getDesktop().open(File(target))
"url" -> Desktop.getDesktop().browse(URI(target))
// Routed through "cmd /c start" (same launch path as double-clicking in
// Explorer or the Run dialog) rather than exec'd directly. A direct
// ProcessBuilder(target).start() calls CreateProcess, which does NOT honor
@@ -110,7 +102,7 @@ object AliasRepository {
// would. This also preserves bare-name resolution via Windows' App Paths
// registry / PATH - not guaranteed to work for every app, since it depends
// on that app having registered itself with Windows.
"run" -> { aliasDebug("ProcessBuilder start \"$target\""); ProcessBuilder("cmd", "/c", "start", "\"\"", target).start() }
"run" -> ProcessBuilder("cmd", "/c", "start", "\"\"", target).start()
else -> println("[AliasRepository] Unknown alias action type '$type' in \"$action\"")
}
} catch (e: Exception) {
+29
View File
@@ -47,6 +47,35 @@ data class IrcMessage(
/** Buffer key: server id + channel/query name (channel names include '#'). */
data class BufferKey(val serverId: String, val name: String)
/**
* wIRC main's own buffer: server messages (MOTD, numerics, errors, all whois output) and
* services/server notices from every connected server. Server ids are built from the host
* with every non-alphanumeric character replaced by '_' (see ServerEditDialog), so one can
* never contain '-' - this id can't collide with a real server. Also used as the
* window_prefs.json key ("wirc-main__main") for the main window's saved position and size.
*/
val MAIN_BUFFER_KEY = BufferKey("wirc-main", "main")
// Nicks that are network services rather than people. Their PRIVMSGs and NOTICEs go to
// wIRC main instead of opening a query window. Anything ending in "serv" is also treated
// as a service, which covers the less common ones (MemoServ, HostServ, SaslServ, ...).
private val KNOWN_SERVICE_NICKS = setOf("nickserv", "chanserv", "global", "alis", "q", "x")
fun isServiceNick(nick: String): Boolean {
val lower = nick.lowercase()
return lower in KNOWN_SERVICE_NICKS || lower.endsWith("serv")
}
/**
* True when a message's source is a service or the server itself rather than a real user.
* A user prefix always has the "nick!user@host" form; a missing prefix or one without '!'
* is a server name (e.g. "irc.rizon.io").
*/
fun isServiceOrServerPrefix(prefix: String?): Boolean {
if (prefix == null || !prefix.contains('!')) return true
return isServiceNick(prefix.substringBefore('!'))
}
data class ChannelUser(
val nick: String,
val isOp: Boolean = false,
-6
View File
@@ -42,16 +42,10 @@ class ServerRepository(rootDir: File) {
}
fun upsert(server: ServerConfig) {
// TEMP DEBUG (see task: on-connect identify command investigation) - confirms the
// Edit Server dialog's Save actually persists onConnectCommands, by writing then
// immediately re-reading the file back rather than trusting the in-memory value.
println("[ServerRepository] upsert(${server.id}): saving onConnectCommands=${server.onConnectCommands}")
val current = loadAll().toMutableList()
val idx = current.indexOfFirst { it.id == server.id }
if (idx >= 0) current[idx] = server else current.add(server)
saveAll(current)
val reloaded = loadAll().firstOrNull { it.id == server.id }
println("[ServerRepository] upsert(${server.id}): reloaded from disk onConnectCommands=${reloaded?.onConnectCommands}")
}
fun remove(serverId: String) {
@@ -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
}
}
}
+95 -20
View File
@@ -5,31 +5,64 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
import java.io.File
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.concurrent.ConcurrentHashMap
// Font sizes offered in the settings dialog (whole sizes only, same range as QuickClaude)
const val MIN_FONT_SIZE = 10
const val MAX_FONT_SIZE = 18
private fun pickerFontSize(size: Float): Float =
Math.round(size).coerceIn(MIN_FONT_SIZE, MAX_FONT_SIZE).toFloat()
data class WindowDisplaySettings(
val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque
val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white
val fontSize: Float = 14f,
val fontId: String = "monospace", // see ui/AppFonts.kt; unknown/missing ids fall back to monospace
val showUserList: Boolean = true,
val mentionHighlightColorArgb: Int = 0x40FFA500.toInt(), // translucent orange
val nickColorsEnabled: Boolean = true,
val showImagePreviews: Boolean = false // opt-in: auto-loading images reveals your IP to the host
)
/**
* A window's last floating position and size, in dp (which Compose Desktop maps 1:1 to
* AWT's logical screen coordinates). Every field is optional in window_prefs.json, so files
* written before this existed still load - a window with no saved bounds just gets a
* cascaded default (see ui/WindowPlacement.kt).
*/
data class WindowBounds(
val x: Float?,
val y: Float?,
val width: Float?,
val height: Float?
)
/**
* Desktop equivalent of the Android DataStore-backed repository. Settings for every
* buffer are kept as one JSON file (rootDir/window_prefs.json), keyed by buffer storage
* id, loaded once at startup and rewritten on every change. Each buffer's settings are
* exposed as a StateFlow so every open window for that buffer updates live, same as
* DataStore's Flow did on Android.
*
* Window bounds live in the same JSON object per key (windowX/windowY/windowWidth/
* windowHeight) but are kept out of the display-settings StateFlow, so moving or resizing
* a window doesn't recompose its content.
*/
class WindowPrefsRepository(rootDir: File) {
private val file = File(rootDir, "window_prefs.json")
private val lock = Any()
private val flows = ConcurrentHashMap<String, MutableStateFlow<WindowDisplaySettings>>()
private var stored: MutableMap<String, WindowDisplaySettings> = loadAll()
private val stored = mutableMapOf<String, WindowDisplaySettings>()
private val storedBounds = mutableMapOf<String, WindowBounds>()
init {
loadAll()
}
fun settingsFor(bufferKeyId: String): StateFlow<WindowDisplaySettings> = flowFor(bufferKeyId).asStateFlow()
@@ -37,7 +70,9 @@ class WindowPrefsRepository(rootDir: File) {
fun setTextColorMircIndex(bufferKeyId: String, index: Int) = update(bufferKeyId) { it.copy(textColorMircIndex = index) }
fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = fontSize) }
fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = pickerFontSize(fontSize)) }
fun setFontId(bufferKeyId: String, fontId: String) = update(bufferKeyId) { it.copy(fontId = fontId) }
fun setShowUserList(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showUserList = show) }
@@ -47,8 +82,18 @@ class WindowPrefsRepository(rootDir: File) {
fun setShowImagePreviews(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showImagePreviews = show) }
fun boundsFor(bufferKeyId: String): WindowBounds? = synchronized(lock) { storedBounds[bufferKeyId] }
fun setWindowBounds(bufferKeyId: String, bounds: WindowBounds) {
synchronized(lock) {
if (storedBounds[bufferKeyId] == bounds) return
storedBounds[bufferKeyId] = bounds
persist()
}
}
private fun flowFor(bufferKeyId: String): MutableStateFlow<WindowDisplaySettings> =
flows.getOrPut(bufferKeyId) { MutableStateFlow(stored[bufferKeyId] ?: WindowDisplaySettings()) }
flows.getOrPut(bufferKeyId) { MutableStateFlow(synchronized(lock) { stored[bufferKeyId] } ?: WindowDisplaySettings()) }
private fun update(bufferKeyId: String, transform: (WindowDisplaySettings) -> WindowDisplaySettings) {
synchronized(lock) {
@@ -59,44 +104,74 @@ class WindowPrefsRepository(rootDir: File) {
}
}
private fun loadAll(): MutableMap<String, WindowDisplaySettings> {
if (!file.exists()) return mutableMapOf()
return try {
private fun JSONObject.optFloatOrNull(key: String): Float? =
if (has(key) && !isNull(key)) optDouble(key).toFloat().takeIf { !it.isNaN() } else null
private fun loadAll() {
if (!file.exists()) return
try {
val obj = JSONObject(file.readText())
val result = mutableMapOf<String, WindowDisplaySettings>()
obj.keys().forEach { key ->
val o = obj.getJSONObject(key)
result[key] = WindowDisplaySettings(
stored[key] = WindowDisplaySettings(
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
textColorMircIndex = o.optInt("textColorMircIndex", 0),
fontSize = o.optDouble("fontSize", 14.0).toFloat(),
// Sizes saved by the old slider (any value from 10 to 24) round to the nearest picker size
fontSize = pickerFontSize(o.optDouble("fontSize", 14.0).toFloat()),
fontId = o.optString("fontId", "monospace"),
showUserList = o.optBoolean("showUserList", true),
mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(),
nickColorsEnabled = o.optBoolean("nickColorsEnabled", true),
showImagePreviews = o.optBoolean("showImagePreviews", false)
)
val bounds = WindowBounds(
x = o.optFloatOrNull("windowX"),
y = o.optFloatOrNull("windowY"),
width = o.optFloatOrNull("windowWidth"),
height = o.optFloatOrNull("windowHeight")
)
if (bounds != WindowBounds(null, null, null, null)) storedBounds[key] = bounds
}
result
} catch (e: Exception) {
mutableMapOf()
stored.clear()
storedBounds.clear()
}
}
// Written to a temp file and moved into place, same as MessageHistoryRepository - this
// file is now rewritten every time a window is moved or resized, so a crash mid-write
// must not be able to leave a truncated file (which loadAll would read as "no settings
// at all" and the next save would make permanent).
private fun persist() {
val obj = JSONObject()
stored.forEach { (key, settings) ->
(stored.keys + storedBounds.keys).forEach { key ->
obj.put(key, JSONObject().apply {
put("backgroundAlpha", settings.backgroundAlpha)
put("textColorMircIndex", settings.textColorMircIndex)
put("fontSize", settings.fontSize)
put("showUserList", settings.showUserList)
put("mentionHighlightColorArgb", settings.mentionHighlightColorArgb)
put("nickColorsEnabled", settings.nickColorsEnabled)
put("showImagePreviews", settings.showImagePreviews)
stored[key]?.let { settings ->
put("backgroundAlpha", settings.backgroundAlpha)
put("textColorMircIndex", settings.textColorMircIndex)
put("fontSize", settings.fontSize)
put("fontId", settings.fontId)
put("showUserList", settings.showUserList)
put("mentionHighlightColorArgb", settings.mentionHighlightColorArgb)
put("nickColorsEnabled", settings.nickColorsEnabled)
put("showImagePreviews", settings.showImagePreviews)
}
storedBounds[key]?.let { bounds ->
bounds.x?.let { put("windowX", it) }
bounds.y?.let { put("windowY", it) }
bounds.width?.let { put("windowWidth", it) }
bounds.height?.let { put("windowHeight", it) }
}
})
}
file.parentFile?.mkdirs()
file.writeText(obj.toString())
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)
}
}
}
+349 -50
View File
@@ -14,6 +14,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
// Per-buffer message history cap. Named so it's a one-line change to temporarily lower
@@ -28,6 +31,29 @@ private const val MESSAGE_HISTORY_CAP = 500
// reported slowness under real message volume.
private const val SAVE_DEBOUNCE_MS = 2500L
// WHOIS reply numerics. Each one's params are [ourNick, targetNick, ...], so the target
// nick (params[1]) is what ties a reply line back to the /whois that asked for it.
// 318 is RPL_ENDOFWHOIS. These only ever come from a WHOIS, so they're shown as whois
// output even when no /whois of ours is pending (e.g. one sent through an alias).
private val WHOIS_NUMERICS = setOf(
"275", "276", "301", "307", "310", "311", "312", "313", "317", "318", "319",
"320", "330", "335", "338", "378", "379", "671"
)
// How long after a /whois is sent that a NOTICE from the server or services still counts
// as a response to it (e.g. a server-side ignore / caller-id notice), and so is labelled
// and grouped under that whois in wIRC main.
private const val WHOIS_NOTICE_WINDOW_MS = 10_000L
// A /whois that never got its 318 (lost reply, server that doesn't send one) stops being
// pending after this long, so stray numerics much later aren't attributed to it.
private const val WHOIS_PENDING_TIMEOUT_MS = 60_000L
// Numerics that only make sense inside a channel window and are already reflected there
// (353 fills the user list, see handleLine) or carry nothing worth showing on their own -
// kept out of wIRC main so joining a channel doesn't flood it.
private val CHANNEL_ONLY_NUMERICS = setOf("332", "333", "353", "366")
/**
* App-wide singleton. Owns every IrcConnection and every ChannelBuffer, so that all
* windows read from the same source of truth via StateFlow.
@@ -47,8 +73,30 @@ object ConnectionManager {
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
// Servers that have a live connection (or one that's connecting/auto-reconnecting after
// a network drop). A server leaves this set only through disconnect()/disconnectAll(),
// and it's what wIRC main's server cards show as Connected / Disconnected.
private val _connectedIds = MutableStateFlow<Set<String>>(emptySet())
val connectedIds: StateFlow<Set<String>> = _connectedIds
// serverId -> channels we were in when /quit disconnected that server, so the next
// connect() rejoins them (and the windows still open for them pick right back up)
// instead of only the configured auto-join list.
private val channelsToRejoin = ConcurrentHashMap<String, MutableSet<String>>()
// A /whois we sent and haven't seen the end of yet. headerShown tracks whether its
// "WHOIS <nick>" header line is already in wIRC main.
private class PendingWhois(val nick: String, val sentAtMillis: Long, var headerShown: Boolean)
// serverId -> (lowercased target nick -> pending whois). Only ever touched from the
// bufferOps consumer coroutine (see enqueue), so a plain map is enough.
private val pendingWhois = HashMap<String, MutableMap<String, PendingWhois>>()
fun init(rootDir: File) {
historyRepo = MessageHistoryRepository(rootDir)
// Seeds wIRC main from its saved history right away, so its scrollback is there at
// startup instead of appearing only once the first server line arrives.
enqueue { ensureBuffer(MAIN_BUFFER_KEY) }
}
private fun setOwnNick(serverId: String, nick: String) {
@@ -56,11 +104,12 @@ object ConnectionManager {
_ownNicks.update { it + (serverId to nick) }
}
// Fires once for every brand-new private-message buffer (a query with someone
// who wasn't already an open window). Main.kt listens to this to auto-open a
// window for it, same as clicking a channel manually.
private val _newQueryBuffers = MutableSharedFlow<BufferKey>(extraBufferCapacity = 16)
val newQueryBuffers: SharedFlow<BufferKey> = _newQueryBuffers
// Fires for every private message to or from a real user (services and the server go
// to wIRC main instead). Main.kt adds the key to its set of open windows, which opens
// the query window if it isn't open - including reopening one the user closed - and
// is a no-op if it already is.
private val _queryWindowRequests = MutableSharedFlow<BufferKey>(extraBufferCapacity = 64)
val queryWindowRequests: SharedFlow<BufferKey> = _queryWindowRequests
// Every buffer state change - incoming lines, local echoes of our own sends - goes
// through this single FIFO queue, processed by one consumer coroutine. Previously each
@@ -92,10 +141,6 @@ object ConnectionManager {
}
fun connect(config: ServerConfig) {
// TEMP DEBUG (see task: on-connect identify command investigation) - confirms
// whether the config this connect() call actually received has the on-connect
// commands the caller thinks it saved.
println("[connmgr-debug] connect(${config.id}) called - onConnectCommands=${config.onConnectCommands}")
val existing = connections[config.id]
if (existing != null) {
// This used to just `return` here, making connect() a permanent no-op for the
@@ -105,48 +150,131 @@ object ConnectionManager {
// `config` was captured once at construction and never replaced. Tearing down
// the old connection and replacing it means a connect() call always actually
// uses whatever was most recently saved.
println("[connmgr-debug] connect(${config.id}) - already connected, replacing with a fresh connection using the latest config")
existing.disconnect()
}
setOwnNick(config.id, config.nick)
val conn = IrcConnection(
// Channels from the connection a /quit ended, or from the one being replaced, so
// they're rejoined along with the auto-join list.
val rejoin = channelsToRejoin.remove(config.id).orEmpty() + existing?.trackedChannels().orEmpty()
lateinit var conn: IrcConnection
conn = IrcConnection(
config = config,
scope = scope,
onLine = { line -> enqueue { handleLine(config.id, line) } },
onStatus = { msg -> _statuses.update { it + (config.id to msg) } }
initialChannels = rejoin,
// Lines and status updates from a connection that's already been disconnected
// or replaced (its socket closing, a last line in flight) are dropped, so
// nothing shows up after the "Disconnected" line or overwrites that status.
onLine = { line -> if (connections[config.id] === conn) enqueue { handleLine(config.id, line) } },
onStatus = { msg ->
if (connections[config.id] === conn) {
_statuses.update { it + (config.id to msg) }
// Connection progress and errors aren't tied to any channel - wIRC main.
enqueue { addMainLine(MessageKind.SYSTEM, null, "[${config.label}] $msg") }
}
}
)
connections[config.id] = conn
_connectedIds.update { it + config.id }
conn.connect()
}
/**
* Bare /quit: disconnects this one server and nothing else. Windows stay open and keep
* their scrollback; every buffer of this server (plus [openKeys], the open windows, in
* case one hasn't had a line yet) gets a "Disconnected" line, and so does wIRC main.
* There's no auto-reconnect afterward - the next connect() (the server card's Connect
* button) rejoins the channels we were in. History is written to disk right away, the
* same as disconnectAll() does on exit.
*/
fun disconnect(serverId: String, openKeys: Collection<BufferKey> = emptyList()) {
val conn = connections.remove(serverId) ?: return
conn.disconnect()
channelsToRejoin[serverId] = conn.trackedChannels().toMutableSet()
_connectedIds.update { it - serverId }
_statuses.update { it + (serverId to "Disconnected") }
val label = conn.config.label
enqueue {
pendingWhois.remove(serverId)
val now = System.currentTimeMillis()
val keys = _buffers.value.keys.filter { it.serverId == serverId } +
openKeys.filter { it.serverId == serverId }
keys.distinct().forEach { key ->
addMessage(key, IrcMessage(now, MessageKind.SYSTEM, null, "Disconnected from $label"))
}
// We're no longer in any channel, so the user lists are stale - the NAMES reply
// on rejoin fills them again.
_buffers.update { map ->
map.mapValues { (key, buf) -> if (key.serverId == serverId) buf.copy(users = emptyList()) else buf }
}
addMainLine(MessageKind.SYSTEM, null, "[$label] Disconnected")
// Runs on the bufferOps queue after the lines above, so they're in the save.
flushAllSaves()
}
}
fun disconnectAll() {
connections.values.forEach { it.disconnect() }
connections.clear()
_connectedIds.value = emptySet()
flushAllSaves()
}
fun isConnected(serverId: String): Boolean = connections.containsKey(serverId)
// /part from a channel window. While disconnected there's no PART to send, but the
// channel is dropped from the rejoin list so reconnecting doesn't rejoin it.
fun partChannel(serverId: String, channel: String, reason: String) {
val conn = connectionFor(serverId)
if (conn != null) conn.partChannel(channel, reason)
else channelsToRejoin[serverId]?.remove(channel)
}
// /join from a channel window. A plain channel list is tracked (see
// IrcConnection.joinChannel) so a reconnect rejoins it; one with a key is sent as-is,
// since rejoining it later without the key wouldn't work anyway.
fun joinChannels(serverId: String, args: String) {
val conn = connectionFor(serverId) ?: return
if (args.contains(' ')) conn.send("JOIN $args")
else args.split(',').filter { it.isNotBlank() }.forEach { conn.joinChannel(it) }
}
fun connectionFor(serverId: String): IrcConnection? = connections[serverId]
// IRC servers don't echo PRIVMSG back to its sender, so a sent message has to be
// added to our own buffer locally - otherwise it would never show up in the window
// that sent it.
fun sendChannelMessage(serverId: String, target: String, text: String) {
connectionFor(serverId)?.sendMessage(target, text)
// No local echo while disconnected - the line was never sent.
val conn = connectionFor(serverId) ?: return
conn.sendMessage(target, text)
val myNick = ownNicks[serverId] ?: return
val key = BufferKey(serverId, target)
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
enqueue { addMessage(key, msg) }
enqueue { addOutgoing(serverId, target, msg, "-> *$target* $text") }
}
// Same local-echo problem as sendChannelMessage, but for /me: the wire form is a
// CTCP ACTION wrapper, while the buffer should just hold the plain action text
// (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix).
fun sendChannelAction(serverId: String, target: String, action: String) {
connectionFor(serverId)?.sendMessage(target, "ACTION $action")
val conn = connectionFor(serverId) ?: return
conn.sendMessage(target, "\u0001ACTION $action\u0001")
val myNick = ownNicks[serverId] ?: return
val key = BufferKey(serverId, target)
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
enqueue { addMessage(key, msg) }
enqueue { addOutgoing(serverId, target, msg, "-> *$target* * $myNick $action") }
}
// Where a local echo goes: a channel's own buffer, wIRC main for a service (e.g.
// "/msg NickServ ..."), or a query buffer - opening its window - for a real user.
private fun addOutgoing(serverId: String, target: String, msg: IrcMessage, mainText: String) {
when {
isChannelName(target) -> addMessage(BufferKey(serverId, target), msg)
isServiceNick(target) -> addMainLine(MessageKind.SYSTEM, null, mainText)
else -> {
val key = BufferKey(serverId, target)
addMessage(key, msg)
_queryWindowRequests.tryEmit(key)
}
}
}
// Raw passthrough for slash commands that don't have dedicated handling.
@@ -154,6 +282,71 @@ object ConnectionManager {
connectionFor(serverId)?.send(raw)
}
/**
* Sends WHOIS. All of its output - the header, every reply line, and any server or
* services notice that comes back in response - goes to wIRC main, whichever window the
* command was typed in. [args] is whatever followed "/whois"; its last word is the nick
* being looked up (the "/whois server nick" form puts the nick last too), and may be a
* comma-separated list.
*/
fun whois(serverId: String, args: String) {
val label = connectionFor(serverId)?.config?.label
val nicks = args.trim().split(Regex("\\s+")).lastOrNull()
?.split(',')?.filter { it.isNotEmpty() }.orEmpty()
if (nicks.isEmpty() || label == null) {
val text = if (nicks.isEmpty()) "[whois] Usage: /whois <nick>" else "[whois] Not connected to this server"
enqueue { addMainLine(MessageKind.SYSTEM, null, text) }
return
}
// Enqueued before the WHOIS goes out, so the header is always in the buffer ahead
// of any reply (replies come in through the same queue, and can't arrive before
// the command is sent). Each further nick of a comma list gets its header when its
// first reply line arrives, so the lines end up grouped under the right one.
enqueue {
val now = System.currentTimeMillis()
val pending = pendingWhois.getOrPut(serverId) { HashMap() }
nicks.forEach { pending[it.lowercase()] = PendingWhois(it, now, headerShown = false) }
showWhoisHeader(serverId, pending.getValue(nicks.first().lowercase()))
}
sendRaw(serverId, "WHOIS ${args.trim()}")
}
private fun showWhoisHeader(serverId: String, pending: PendingWhois) {
if (pending.headerShown) return
pending.headerShown = true
val label = connectionFor(serverId)?.config?.label ?: serverId
// \u0002 is the mIRC bold toggle, so the header stands out from the reply lines.
addMainLine(MessageKind.SYSTEM, null, "\u0002===== WHOIS ${pending.nick} ($label) =====\u0002")
}
// Pending whois for this server, with any that timed out dropped first.
private fun livePendingWhois(serverId: String): MutableMap<String, PendingWhois>? {
val pending = pendingWhois[serverId] ?: return null
val cutoff = System.currentTimeMillis() - WHOIS_PENDING_TIMEOUT_MS
pending.values.removeAll { it.sentAtMillis < cutoff }
return pending
}
// A numeric is whois output if it's one of WHOIS_NUMERICS, or if it names a nick we
// have a /whois pending for (401/402 "no such nick/server", 716 "is in +g mode
// (server-side ignore)", ...), or if it's about the WHOIS command itself (263
// "try again", 481, ...) while one is pending.
private fun isWhoisNumeric(serverId: String, line: ParsedLine): Boolean {
if (line.command in WHOIS_NUMERICS) return true
if (!line.command.all { it.isDigit() }) return false
val pending = livePendingWhois(serverId)
if (pending.isNullOrEmpty()) return false
val subject = line.params.getOrNull(1) ?: return false
return subject.lowercase() in pending || subject.equals("WHOIS", ignoreCase = true)
}
// The most recently sent /whois on this server, if it went out recently enough that a
// server or services NOTICE arriving now is plausibly a response to it.
private fun whoisAwaitingNotice(serverId: String): PendingWhois? {
val recent = livePendingWhois(serverId)?.values?.maxByOrNull { it.sentAtMillis } ?: return null
return recent.takeIf { System.currentTimeMillis() - it.sentAtMillis <= WHOIS_NOTICE_WINDOW_MS }
}
// Local-only feedback (e.g. the /reload confirmation) that never touches the
// wire - just appended to the buffer the same way any other SYSTEM line is.
fun addSystemMessage(serverId: String, target: String, text: String) {
@@ -177,12 +370,6 @@ object ConnectionManager {
if (_buffers.value.containsKey(key)) return
val seeded = historyRepo?.loadMessages(key) ?: emptyList()
_buffers.update { map -> map + (key to ChannelBuffer(key, messages = seeded)) }
if (!isChannelName(key.name)) {
// tryEmit, not emit: this runs on the single, non-suspend bufferOps consumer,
// not its own coroutine, so there's nothing to suspend into. The SharedFlow's
// 16-slot extraBufferCapacity means this can't actually fail in practice.
_newQueryBuffers.tryEmit(key)
}
}
private fun addMessage(key: BufferKey, msg: IrcMessage) {
@@ -195,6 +382,10 @@ object ConnectionManager {
scheduleSave(key)
}
private fun addMainLine(kind: MessageKind, sender: String?, text: String) {
addMessage(MAIN_BUFFER_KEY, IrcMessage(System.currentTimeMillis(), kind, sender, text))
}
// Coalesces rapid-fire saves for the same buffer into at most one write every
// SAVE_DEBOUNCE_MS, instead of a full history save (up to 500 messages) on every single
// incoming message - the direct cause of reported slowness in a busy channel. Reads
@@ -229,6 +420,67 @@ object ConnectionManager {
}
}
// Every WHOIS reply line goes to wIRC main, under its "WHOIS <nick>" header. A reply
// with no pending /whois (one sent through an alias or a raw command) gets a header
// of its own the same way, so every whois result in wIRC main starts with one.
private fun handleWhoisLine(serverId: String, line: ParsedLine) {
val target = line.params.getOrNull(1) ?: return
val pending = livePendingWhois(serverId)
val entry = pending?.get(target.lowercase())
?: if (target.equals("WHOIS", ignoreCase = true)) pending?.values?.maxByOrNull { it.sentAtMillis } else null
when {
entry != null -> showWhoisHeader(serverId, entry)
line.command in WHOIS_NUMERICS && line.command != "318" -> {
val unsolicited = PendingWhois(target, System.currentTimeMillis(), headerShown = false)
pendingWhois.getOrPut(serverId) { HashMap() }[target.lowercase()] = unsolicited
showWhoisHeader(serverId, unsolicited)
}
}
if (line.command == "318") {
// A comma-list whois ends with a single 318 naming the whole list.
target.split(',').forEach { pending?.remove(it.lowercase()) }
}
addMainLine(MessageKind.SYSTEM, null, "[whois] " + formatWhoisLine(line))
}
private fun formatWhoisLine(line: ParsedLine): String {
val p = line.params
val nick = p.getOrNull(1) ?: ""
return when (line.command) {
"311" -> "$nick is ${p.getOrNull(2)}@${p.getOrNull(3)} (${p.lastOrNull() ?: ""})"
"312" -> "$nick is on ${p.getOrNull(2)} (${p.lastOrNull() ?: ""})"
"317" -> {
val idleSecs = p.getOrNull(2)?.toLongOrNull()
val signon = p.getOrNull(3)?.toLongOrNull()
buildString {
append("$nick has been idle ${idleSecs?.let { formatDuration(it) } ?: "?"}")
if (signon != null) {
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
append(", signed on ${fmt.format(Date(signon * 1000))}")
}
}
}
"319" -> "$nick is on channels: ${p.lastOrNull() ?: ""}"
"330" -> "$nick ${p.lastOrNull() ?: "is logged in as"} ${p.getOrNull(2) ?: ""}".trim()
"301" -> "$nick is away: ${p.lastOrNull() ?: ""}"
"318" -> "End of WHOIS for $nick"
else -> p.drop(1).joinToString(" ")
}
}
private fun formatDuration(totalSecs: Long): String {
val d = totalSecs / 86400
val h = (totalSecs % 86400) / 3600
val m = (totalSecs % 3600) / 60
val s = totalSecs % 60
return buildString {
if (d > 0) append("${d}d ")
if (d > 0 || h > 0) append("${h}h ")
if (d > 0 || h > 0 || m > 0) append("${m}m ")
append("${s}s")
}
}
private fun handleLine(serverId: String, line: ParsedLine) {
val now = System.currentTimeMillis()
val nick = nickFromPrefix(line.prefix) ?: line.prefix
@@ -237,16 +489,51 @@ object ConnectionManager {
"PRIVMSG" -> {
val target = line.params.getOrNull(0) ?: return
val text = line.params.getOrNull(1) ?: return
val isAction = text.startsWith("ACTION") && text.endsWith("")
val body = if (isAction) text.removePrefix("ACTION ").removeSuffix("") else text
// Channel messages are keyed by the channel name. Private messages arrive
// with target == our own nick, so key those by the *sender* instead -
// that's the conversation identity a query window should use.
val bufferName = if (isChannelName(target)) target else (nick ?: target)
addMessage(
BufferKey(serverId, bufferName),
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
)
val isCtcp = text.length > 1 && text.startsWith("\u0001") && text.endsWith("\u0001")
val isAction = isCtcp && text.startsWith("\u0001ACTION")
val body = if (isAction) text.removePrefix("\u0001ACTION ").removeSuffix("\u0001") else text
if (isChannelName(target)) {
addMessage(
BufferKey(serverId, target),
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
)
} else if (isServiceOrServerPrefix(line.prefix) || (isCtcp && !isAction)) {
// Services, the server itself, and CTCP requests other than ACTION
// (PING, TIME, ...) go to wIRC main instead of opening a query window.
// (VERSION is already answered and swallowed in IrcConnection.)
val shown = if (isCtcp && !isAction) "[CTCP ${text.trim('\u0001')} from $nick]" else body
addMainLine(MessageKind.CHAT, nick, shown)
} else {
// A private message from a real user arrives with target == our own
// nick, so key it by the *sender* - that's the conversation identity a
// query window should use - and (re)open that window.
val key = BufferKey(serverId, nick ?: target)
addMessage(key, IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body))
_queryWindowRequests.tryEmit(key)
}
}
"NOTICE" -> {
val target = line.params.getOrNull(0) ?: return
val text = line.params.getOrNull(1) ?: return
if (isChannelName(target)) {
addMessage(BufferKey(serverId, target), IrcMessage(now, MessageKind.NOTICE, nick, text))
} else if (isServiceOrServerPrefix(line.prefix)) {
// NickServ/ChanServ replies (identify success/failure etc.) and server
// notices - wIRC main. The sender goes into the text because the
// NOTICE row layout doesn't show a nick column. One arriving right
// after a /whois (e.g. a server-side ignore notice) is labelled as part
// of that whois and goes under its header.
val whois = whoisAwaitingNotice(serverId)
if (whois != null) showWhoisHeader(serverId, whois)
val label = if (whois != null) "[whois] " else ""
addMainLine(MessageKind.NOTICE, nick, "$label-${nick ?: "server"}- $text")
} else {
// A notice directed AT us arrives with target == our own nick, so key
// it by the sender, same as a private message.
val key = BufferKey(serverId, nick ?: target)
addMessage(key, IrcMessage(now, MessageKind.NOTICE, nick, text))
_queryWindowRequests.tryEmit(key)
}
}
"JOIN" -> {
val channel = line.params.getOrNull(0) ?: return
@@ -270,9 +557,13 @@ object ConnectionManager {
}
"MODE" -> {
val channel = line.params.getOrNull(0) ?: return
if (!channel.startsWith("#")) return
val modeStr = line.params.getOrNull(1) ?: ""
val modeTarget = line.params.getOrNull(2)
if (!isChannelName(channel)) {
// User mode on our own nick (e.g. +i, +r after identifying) - wIRC main.
addMainLine(MessageKind.MODE, nick, "$nick sets mode $modeStr on $channel")
return
}
addMessage(
BufferKey(serverId, channel),
IrcMessage(now, MessageKind.MODE, nick, "$nick sets mode $modeStr ${modeTarget ?: ""}".trim())
@@ -315,20 +606,6 @@ object ConnectionManager {
}
updateUsers(BufferKey(serverId, channel)) { existing -> (existing + parsedUsers).distinctBy { it.nick } }
}
"NOTICE" -> {
val target = line.params.getOrNull(0) ?: return
val text = line.params.getOrNull(1) ?: return
// Same fix PRIVMSG already had: a notice directed AT us arrives with
// target == our own nick, not the sender. This mattered a lot in practice -
// NickServ/ChanServ almost universally reply to commands (IDENTIFY, etc.)
// via NOTICE rather than PRIVMSG, so without this fallback every identify
// success/failure notice was being filed under a buffer keyed by our OWN
// nick - which no window ever opens for - instead of under "NickServ",
// making it look like the automatic identify produced no feedback at all,
// whether or not it actually worked.
val bufferName = if (isChannelName(target)) target else (nick ?: target)
addMessage(BufferKey(serverId, bufferName), IrcMessage(now, MessageKind.NOTICE, nick, text))
}
"NICK" -> {
val oldNick = nick ?: return
val newNick = line.params.getOrNull(0) ?: return
@@ -347,6 +624,7 @@ object ConnectionManager {
}
if (ownNicks[serverId] == oldNick) {
setOwnNick(serverId, newNick)
addMainLine(MessageKind.NICK, null, "You are now known as $newNick")
}
}
"QUIT" -> {
@@ -360,6 +638,27 @@ object ConnectionManager {
}
}
}
"INVITE" -> {
val channel = line.params.getOrNull(1) ?: return
addMainLine(MessageKind.SYSTEM, nick, "$nick invited you to $channel")
}
"ERROR" -> {
addMainLine(MessageKind.SYSTEM, null, "ERROR: ${line.params.joinToString(" ")}")
}
"PONG", "CAP" -> Unit
else -> {
when {
isWhoisNumeric(serverId, line) -> handleWhoisLine(serverId, line)
line.command in CHANNEL_ONLY_NUMERICS -> Unit
// Every other numeric (welcome, ISUPPORT, LUSERS, MOTD, errors, ...) is a
// server message; params[0] is just our own nick, so it's dropped.
line.command.all { it.isDigit() } ->
addMainLine(MessageKind.SYSTEM, line.prefix, line.params.drop(1).joinToString(" "))
// Anything else not tied to a channel (WALLOPS, KILL, ...) - wIRC main as-is.
else ->
addMainLine(MessageKind.SYSTEM, nick, "${line.command} ${line.params.joinToString(" ")}".trim())
}
}
}
}
}
+18 -2
View File
@@ -50,6 +50,9 @@ private fun translateOnConnectCommand(cmd: String): String {
class IrcConnection(
val config: ServerConfig,
private val scope: CoroutineScope,
// Channels to rejoin on top of config.autoJoinChannels - the ones a previous connection
// to this server was in (see ConnectionManager.connect).
initialChannels: Collection<String> = emptyList(),
private val onLine: (ParsedLine) -> Unit,
private val onStatus: (String) -> Unit
) {
@@ -60,13 +63,17 @@ class IrcConnection(
@Volatile private var socket: Socket? = null
@Volatile private var writer: PrintWriter? = null
private var readJob: Job? = null
private var wantConnected = false
// @Volatile: disconnect() clears it from the UI thread while the read loop checks it on
// an IO thread - it's what stops the loop from auto-reconnecting after a /quit.
@Volatile private var wantConnected = false
private var backoffMillis = 2000L
// Every channel we're currently in, whether from config.autoJoinChannels or joined
// manually mid-session - so a reconnect rejoins everything, not just the original
// auto-join list.
private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet<String>()
private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet<String>(initialChannels)
fun trackedChannels(): Set<String> = joinedChannels.toSet()
// Guards runPostRegistration() against running twice for the same connection cycle
// (376 and 422 are mutually exclusive per RFC, but this is cheap insurance) - reset
@@ -121,6 +128,9 @@ class IrcConnection(
Socket(config.host, config.port)
}
socket = sock
// disconnect() can't interrupt a socket that's still being opened, so
// check here before registering on a connection nobody wants anymore.
ensureActive()
val out = PrintWriter(OutputStreamWriter(sock.getOutputStream(), Charsets.UTF_8), true)
writer = out
@@ -161,6 +171,9 @@ class IrcConnection(
handleRawLine(line)
}
} catch (e: Exception) {
// After disconnect() the read fails because we closed the socket
// ourselves - that's not an error worth reporting.
if (!wantConnected) break
onStatus("Connection error: ${e.message}")
ircDebug(config.id, "connection dropped: ${e.message} - tracked channels at drop time: $joinedChannels")
} finally {
@@ -277,6 +290,9 @@ class IrcConnection(
try { writer?.println("QUIT :leaving") } catch (_: Exception) {}
readJob?.cancel()
closeSocketQuietly()
// Ends the outbox consumer - this connection is never reused (a reconnect
// makes a new IrcConnection), so it would otherwise idle forever.
outbox.close()
}
}
+111
View File
@@ -0,0 +1,111 @@
package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.platform.Font
import java.io.File
/**
* Fonts offered in each window's settings. Stored in window_prefs.json by [id], so the ids
* must never change once shipped.
*
* Two kinds:
* - Windows fonts (Segoe UI, Tahoma, Tw Cen MT) are Microsoft-licensed, so they are NOT
* bundled. They are loaded straight out of the Windows font folders at runtime and only
* listed when the file is actually there (Tw Cen MT only exists if Office is installed).
* - Rounded fonts (Nunito, Quicksand, Varela Round) are SIL OFL licensed and bundled in
* src/main/resources/fonts with their license files.
*
* Anything missing or failing to load falls back to Monospace, so a font uninstalled after
* being picked can never crash a window.
*/
data class FontOption(val id: String, val label: String, val family: FontFamily)
object AppFonts {
const val DEFAULT_ID = "monospace"
private val fontDirs: List<File> = buildList {
add(File(System.getenv("WINDIR") ?: "C:\\Windows", "Fonts"))
System.getenv("LOCALAPPDATA")?.let { add(File(it, "Microsoft\\Windows\\Fonts")) }
}
/** Case-insensitive lookup across the system and per-user font folders. */
private fun findSystemFont(name: String): File? {
for (dir in fontDirs) {
val direct = File(dir, name)
if (direct.isFile) return direct
dir.listFiles()?.firstOrNull { it.name.equals(name, ignoreCase = true) }?.let { return it }
}
return null
}
private fun systemFamily(regular: String, bold: String?): FontFamily? {
val reg = findSystemFont(regular) ?: return null
return try {
val fonts = mutableListOf(Font(reg, FontWeight.Normal))
bold?.let { findSystemFont(it) }?.let { fonts.add(Font(it, FontWeight.Bold)) }
FontFamily(fonts)
} catch (e: Exception) {
null
}
}
private fun bundledFamily(regular: String, bold: String?): FontFamily? = try {
val fonts = mutableListOf(Font("fonts/$regular", FontWeight.Normal))
bold?.let { fonts.add(Font("fonts/$it", FontWeight.Bold)) }
FontFamily(fonts)
} catch (e: Exception) {
null
}
/** Built once at startup; the font folders don't change while wIRC is running. */
val options: List<FontOption> by lazy {
buildList {
add(FontOption(DEFAULT_ID, "Monospace", FontFamily.Monospace))
systemFamily("segoeui.ttf", "segoeuib.ttf")?.let { add(FontOption("segoe-ui", "Segoe UI", it)) }
systemFamily("tahoma.ttf", "tahomabd.ttf")?.let { add(FontOption("tahoma", "Tahoma", it)) }
systemFamily("TCM_____.TTF", "TCB_____.TTF")?.let { add(FontOption("tw-cen-mt", "Tw Cen MT", it)) }
bundledFamily("Nunito-Regular.ttf", "Nunito-Bold.ttf")?.let { add(FontOption("nunito", "Nunito (rounded)", it)) }
bundledFamily("Quicksand-Regular.ttf", "Quicksand-Bold.ttf")?.let { add(FontOption("quicksand", "Quicksand (rounded)", it)) }
bundledFamily("VarelaRound-Regular.ttf", null)?.let { add(FontOption("varela-round", "Varela Round (rounded)", it)) }
}
}
fun familyFor(id: String): FontFamily =
options.firstOrNull { it.id == id }?.family ?: FontFamily.Monospace
}
/**
* The font list used in both the channel-window settings and wIRC main's Font dialog. Each
* name is drawn in its own font as a live preview; plain square-cornered rows, no dropdown.
*/
@Composable
fun FontPickerList(selectedId: String, textColor: Color, onPick: (String) -> Unit) {
val effectiveId = if (AppFonts.options.any { it.id == selectedId }) selectedId else AppFonts.DEFAULT_ID
Column {
AppFonts.options.forEach { option ->
Text(
text = option.label,
color = textColor,
fontFamily = option.family,
fontSize = 15.sp,
modifier = Modifier
.fillMaxWidth()
.background(if (option.id == effectiveId) Color.White.copy(alpha = 0.18f) else Color.Transparent)
.clickable { onPick(option.id) }
.padding(horizontal = 6.dp, vertical = 4.dp)
)
}
}
}
+131
View File
@@ -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))
}
}
File diff suppressed because it is too large Load Diff
+57 -13
View File
@@ -3,6 +3,7 @@ package ui
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
@@ -10,8 +11,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.rememberWindowState
import data.AliasRepository
import data.BufferKey
import data.WindowDisplaySettings
@@ -19,9 +21,20 @@ import data.WindowPrefsRepository
import data.isChannelName
import data.storageId
import irc.ConnectionManager
import irc.IrcConnection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private const val NOT_CONNECTED_TEXT = "Not connected - use Connect in wIRC main to reconnect"
// Runs [action] on this server's connection, or shows the not-connected line in this
// window when there isn't one (e.g. the user-list kick/ban/op menu after a /quit).
private fun withConnection(serverId: String, windowName: String, action: (IrcConnection) -> Unit) {
val conn = ConnectionManager.connectionFor(serverId)
if (conn != null) action(conn)
else ConnectionManager.addSystemMessage(serverId, windowName, NOT_CONNECTED_TEXT)
}
/**
* One of these is composed per open buffer key (see Main.kt) - each is a real, independent
* top-level OS window, same intent as ChannelWindowActivity.kt's per-channel Activity on
@@ -33,9 +46,11 @@ fun ChannelWindow(
bufferKey: BufferKey,
prefsRepo: WindowPrefsRepository,
colorScheme: ColorScheme,
visible: Boolean,
onHide: () -> Unit,
onOpenBuffer: (BufferKey) -> Unit,
onCloseSelf: () -> Unit,
onQuitApp: () -> Unit
onDisconnectServer: () -> Unit
) {
val serverId = bufferKey.serverId
val channelName = bufferKey.name
@@ -59,15 +74,23 @@ fun ChannelWindow(
val buffer by remember(bufferKey) { derivedStateOf { buffers[bufferKey] } }
val ownNicks by ConnectionManager.ownNicksFlow.collectAsState()
val ownNick by remember(serverId) { derivedStateOf { ownNicks[serverId] } }
val windowState = rememberWindowState()
// Restored from window_prefs.json (or cascaded if there's no usable saved position)
// and saved back on move/resize/close - see WindowPlacement.kt.
val windowState = rememberPersistentWindowState(prefsRepo, bufferKey.storageId(), DpSize(800.dp, 600.dp))
// Snap to the desktop grid after a move, and to whole lines after a resize
val lineGap = rememberLineGap()
WindowSnapping(windowState, lineGap)
// undecorated + transparent trade the native OS title bar for real per-pixel window
// transparency (Windows only honors transparency on undecorated windows) - ChannelScreen
// supplies its own title bar with minimize/close/drag/resize to make up for what the
// OS chrome would otherwise provide. No Surface wrapper here (M3 Surface paints an
// opaque background) - ChannelScreen already paints its own alpha-aware background.
// Hidden to the tray when [visible] is false - the window and its composition stay
// alive, so its buffer keeps filling while hidden.
Window(
onCloseRequest = onCloseSelf,
visible = visible,
title = windowTitle,
icon = painterResource("icon.png"),
state = windowState,
@@ -75,16 +98,30 @@ fun ChannelWindow(
transparent = true,
resizable = true
) {
TrayWindowBehavior(windowState, onHide)
CompositionLocalProvider(LocalLineGap provides lineGap) {
MaterialTheme(colorScheme = colorScheme) {
ChannelScreen(
windowState = windowState,
onMinimize = onHide,
onCloseWindow = onCloseSelf,
channelName = windowTitle,
buffer = buffer,
displaySettings = displaySettings,
colorScheme = colorScheme,
ownNick = ownNick,
onSend = { text ->
onSend = onSend@{ text ->
// Everything except /reload, aliases and /part (which only closes
// the window) needs the server - while disconnected it gets this
// line in this window instead of going nowhere.
val needsServer = if (text.startsWith("/")) {
val command = text.substring(1).substringBefore(' ').uppercase()
command != "RELOAD" && command !in setOf("PART", "EXIT", "CLOSE") &&
!AliasRepository.hasAlias(command)
} else true
if (needsServer && !ConnectionManager.isConnected(serverId)) {
ConnectionManager.addSystemMessage(serverId, channelName, NOT_CONNECTED_TEXT)
return@onSend
}
if (text.startsWith("/")) {
val withoutSlash = text.substring(1)
val spaceIdx = withoutSlash.indexOf(' ')
@@ -109,21 +146,24 @@ fun ChannelWindow(
"NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args")
"PART", "EXIT", "CLOSE" -> {
val target = args.ifBlank { channelName }
ConnectionManager.connectionFor(serverId)?.partChannel(target, "leaving")
ConnectionManager.partChannel(serverId, target, "leaving")
if (args.isBlank() || target == channelName) onCloseSelf()
}
"QUIT" -> {
if (rawArgs.isBlank()) {
ConnectionManager.disconnectAll()
onQuitApp()
// Disconnects this window's server only - every
// window stays open; see ConnectionManager.disconnect.
onDisconnectServer()
} else {
ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs)
}
}
"JOIN" -> {
ConnectionManager.sendRaw(serverId, "JOIN $args")
ConnectionManager.joinChannels(serverId, args)
onOpenBuffer(BufferKey(serverId, args))
}
// All whois output goes to wIRC main, not this window.
"WHOIS" -> ConnectionManager.whois(serverId, args)
"MSG" -> {
val msgSpaceIdx = args.indexOf(' ')
if (msgSpaceIdx >= 0) {
@@ -139,10 +179,10 @@ fun ChannelWindow(
ConnectionManager.sendChannelMessage(serverId, channelName, text)
}
},
onKick = { nick -> ConnectionManager.connectionFor(serverId)?.kick(channelName, nick) },
onBan = { nick -> ConnectionManager.connectionFor(serverId)?.ban(channelName, "$nick!*@*") },
onOp = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "+o", nick) },
onDeop = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "-o", nick) },
onKick = { nick -> withConnection(serverId, channelName) { it.kick(channelName, nick) } },
onBan = { nick -> withConnection(serverId, channelName) { it.ban(channelName, "$nick!*@*") } },
onOp = { nick -> withConnection(serverId, channelName) { it.setMode(channelName, "+o", nick) } },
onDeop = { nick -> withConnection(serverId, channelName) { it.setMode(channelName, "-o", nick) } },
onAlphaChange = { alpha ->
scope.launch(Dispatchers.IO) { prefsRepo.setAlpha(bufferKey.storageId(), alpha) }
},
@@ -152,6 +192,9 @@ fun ChannelWindow(
onFontSizeChange = { size ->
scope.launch(Dispatchers.IO) { prefsRepo.setFontSize(bufferKey.storageId(), size) }
},
onFontChange = { id ->
scope.launch(Dispatchers.IO) { prefsRepo.setFontId(bufferKey.storageId(), id) }
},
onShowUserListChange = { show ->
scope.launch(Dispatchers.IO) { prefsRepo.setShowUserList(bufferKey.storageId(), show) }
},
@@ -166,5 +209,6 @@ fun ChannelWindow(
}
)
}
}
}
}
+122 -23
View File
@@ -1,5 +1,7 @@
package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -11,7 +13,9 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isShiftPressed
@@ -23,9 +27,12 @@ import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogWindow
import androidx.compose.ui.window.rememberDialogState
import data.MAIN_BUFFER_KEY
import data.ServerConfig
import data.ServerRepository
import data.WindowDisplaySettings
import irc.ConnectionManager
import irc.mircPalette
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -33,17 +40,69 @@ import kotlinx.coroutines.launch
fun MainScreen(
repo: ServerRepository,
colorScheme: ColorScheme,
displaySettings: WindowDisplaySettings,
autoConnectServerIds: Set<String>,
onOpenChannel: (serverId: String, channelName: String) -> Unit,
onExitApp: () -> Unit
onExitApp: () -> Unit,
onUnreadChange: (Int) -> Unit,
onFontChange: (String) -> Unit
) {
var showFontDialog by remember { mutableStateOf(false) }
var servers by remember { mutableStateOf(repo.loadAll()) }
var showAddDialog by remember { mutableStateOf(false) }
var editingServer by remember { mutableStateOf<ServerConfig?>(null) }
var connectedIds by remember { mutableStateOf(setOf<String>()) }
// From ConnectionManager rather than local state, so a /quit typed in a channel window
// flips that server's card back to Disconnected with its Connect button.
val connectedIds by ConnectionManager.connectedIds.collectAsState()
var channelInputs by remember { mutableStateOf(mapOf<String, String>()) }
var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
val coroutineScope = rememberCoroutineScope()
fun connectServer(server: ServerConfig) {
// Also rejoins the channels this server was in before a /quit - their
// windows are still open, and opening one that already is is a no-op.
ConnectionManager.connect(server)
if (server.autoJoinChannels.isNotEmpty()) {
// Auto-join sends JOIN correctly on connect (see IrcConnection),
// but nothing else opens a window for it like the manual
// "Join + Open" flow does - so give the server a moment to
// actually join us before opening windows for them.
coroutineScope.launch {
delay(500)
server.autoJoinChannels.forEach { ch ->
val current = openedChannels[server.id] ?: emptyList()
if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch))
onOpenChannel(server.id, ch)
}
}
}
}
// Servers to connect at startup, as if Connect had been clicked (see Main.kt)
LaunchedEffect(Unit) {
servers.filter { it.id in autoConnectServerIds }.forEach { connectServer(it) }
}
val buffers by ConnectionManager.buffers.collectAsState()
val mainMessages by remember { derivedStateOf { buffers[MAIN_BUFFER_KEY]?.messages ?: emptyList() } }
val textColor = mircPalette[displaySettings.textColorMircIndex]
// Unread indicator: lines that arrived in wIRC main while this window didn't have focus
// (e.g. while typing in a channel window). Shown next to the "Server messages" label and,
// via onUnreadChange, in the window title and the tray tooltip. Cleared as soon as the window is
// focused. Compared by timestamp rather than count so history loaded at startup never
// counts as new.
val windowFocused = LocalWindowInfo.current.isWindowFocused
var lastSeenMillis by remember { mutableStateOf(System.currentTimeMillis()) }
val newest = mainMessages.lastOrNull()
LaunchedEffect(windowFocused, newest?.seq) {
if (windowFocused) {
lastSeenMillis = maxOf(lastSeenMillis, newest?.timestampMillis ?: 0L, System.currentTimeMillis())
}
}
val unread = if (windowFocused) 0 else mainMessages.count { it.timestampMillis > lastSeenMillis }
LaunchedEffect(unread) { onUnreadChange(unread) }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -56,9 +115,14 @@ fun MainScreen(
Text("+ Add Server")
}
Spacer(Modifier.width(8.dp))
// Same end result as typing "/quit" alone in a channel window - disconnects
// every server and terminates the process - just reachable without needing
// a channel window open first.
// Font for the server messages box (channel windows pick theirs in their own
// settings).
TextButton(onClick = { showFontDialog = true }) {
Text("Font")
}
Spacer(Modifier.width(8.dp))
// Disconnects every server and terminates the process. (A bare "/quit" in a
// channel window only disconnects that window's server.)
TextButton(onClick = onExitApp) {
Text("Exit wIRC")
}
@@ -78,24 +142,7 @@ fun MainScreen(
channelInput = channelInputs[server.id] ?: "#",
openedChannels = openedChannels[server.id] ?: emptyList(),
onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) },
onConnect = {
ConnectionManager.connect(server)
connectedIds = connectedIds + server.id
if (server.autoJoinChannels.isNotEmpty()) {
// Auto-join sends JOIN correctly on connect (see IrcConnection),
// but nothing else opens a window for it like the manual
// "Join + Open" flow does - so give the server a moment to
// actually join us before opening windows for them.
coroutineScope.launch {
delay(500)
server.autoJoinChannels.forEach { ch ->
val current = openedChannels[server.id] ?: emptyList()
if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch))
onOpenChannel(server.id, ch)
}
}
}
},
onConnect = { connectServer(server) },
onEdit = { editingServer = server; showAddDialog = true },
onDelete = {
repo.remove(server.id)
@@ -125,6 +172,54 @@ fun MainScreen(
Spacer(Modifier.height(12.dp))
}
}
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Server messages", style = MaterialTheme.typography.labelMedium)
if (unread > 0) {
Spacer(Modifier.width(8.dp))
Text(
"\u25CF $unread new",
color = UNREAD_INDICATOR_COLOR,
style = MaterialTheme.typography.labelMedium
)
}
}
Spacer(Modifier.height(4.dp))
// MOTD, numerics, errors, services/server notices and all /whois output - see
// ConnectionManager. Same rendering, auto-scroll and text selection as a channel
// window.
MessageScrollback(
messages = mainMessages,
displaySettings = displaySettings,
textColor = textColor,
isMention = { false },
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(Color.Black.copy(alpha = displaySettings.backgroundAlpha))
.border(1.dp, Color.Gray.copy(alpha = 0.5f))
.padding(vertical = 2.dp)
)
}
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) {
@@ -141,6 +236,8 @@ fun MainScreen(
}
}
private val UNREAD_INDICATOR_COLOR = Color(0xFFFFA500)
@Composable
private fun ServerCard(
server: ServerConfig,
@@ -183,6 +280,8 @@ private fun ServerCard(
Spacer(Modifier.height(8.dp))
if (!isConnected) {
Text("Disconnected", style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(4.dp))
Button(onClick = onConnect) { Text("Connect") }
} else {
Text("Connected", style = MaterialTheme.typography.bodySmall)
+158
View File
@@ -0,0 +1,158 @@
package ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
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 androidx.compose.ui.window.rememberWindowState
import data.WindowBounds
import data.WindowPrefsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.withContext
import java.awt.GraphicsEnvironment
import java.awt.Rectangle
import java.awt.Toolkit
// How long a window has to sit still after a move/resize before its bounds are written
// to window_prefs.json - dragging fires a position change per mouse event.
private const val BOUNDS_SAVE_DEBOUNCE_MS = 500L
// Cascade layout for windows with no usable saved position: the first one opens
// CASCADE_MARGIN_DP in from the top-left of the primary screen's usable area (taskbar
// excluded), each next one CASCADE_STEP_DP further down and right, wrapping back to the
// start before a window would run off the screen.
private const val CASCADE_MARGIN_DP = 40
private const val CASCADE_STEP_DP = 32
// A saved position only counts as "on a connected monitor" if at least this much of the
// window's top strip (where the drag handle / title bar is) lands on one screen, so a
// restored window can always be grabbed and moved.
private const val TITLE_STRIP_HEIGHT_DP = 32
private const val MIN_VISIBLE_TITLE_WIDTH_DP = 80
// Saved sizes smaller than this are treated as invalid and replaced by the default.
private const val MIN_WINDOW_WIDTH_DP = 200
private const val MIN_WINDOW_HEIGHT_DP = 120
private object WindowCascade {
private var next = 0
@Synchronized
fun nextPosition(size: DpSize): WindowPosition {
val area = primaryUsableArea()
val roomX = area.width - CASCADE_MARGIN_DP - size.width.value
val roomY = area.height - CASCADE_MARGIN_DP - size.height.value
val stepsThatFit = (minOf(roomX, roomY) / CASCADE_STEP_DP).toInt().coerceAtLeast(0)
val step = next++ % (stepsThatFit + 1)
val offset = CASCADE_MARGIN_DP + step * CASCADE_STEP_DP
return WindowPosition.Absolute((area.x + offset).dp, (area.y + offset).dp)
}
}
private fun primaryUsableArea(): Rectangle = try {
val config = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration
val bounds = config.bounds
val insets = Toolkit.getDefaultToolkit().getScreenInsets(config)
Rectangle(
bounds.x + insets.left,
bounds.y + insets.top,
bounds.width - insets.left - insets.right,
bounds.height - insets.top - insets.bottom
)
} catch (e: Exception) {
Rectangle(0, 0, 1280, 720)
}
private fun screenBounds(): List<Rectangle> = try {
GraphicsEnvironment.getLocalGraphicsEnvironment().screenDevices.map { it.defaultConfiguration.bounds }
} catch (e: Exception) {
emptyList()
}
private fun isOnConnectedScreen(x: Float, y: Float, width: Float): Boolean {
val strip = Rectangle(x.toInt(), y.toInt(), width.toInt().coerceAtLeast(1), TITLE_STRIP_HEIGHT_DP)
val minVisibleWidth = minOf(MIN_VISIBLE_TITLE_WIDTH_DP, strip.width)
return screenBounds().any { screen ->
val visible = screen.intersection(strip)
!visible.isEmpty && visible.width >= minVisibleWidth && visible.height >= TITLE_STRIP_HEIGHT_DP / 2
}
}
private fun resolveInitialPlacement(saved: WindowBounds?, defaultSize: DpSize): Pair<WindowPosition, DpSize> {
val savedWidth = saved?.width
val savedHeight = saved?.height
val size = if (savedWidth != null && savedHeight != null &&
savedWidth >= MIN_WINDOW_WIDTH_DP && savedHeight >= MIN_WINDOW_HEIGHT_DP
) {
// Never restore a window bigger than the largest connected screen (e.g. it was
// saved on a monitor that has since been unplugged).
val screens = screenBounds()
val maxWidth = screens.maxOfOrNull { it.width }?.toFloat() ?: savedWidth
val maxHeight = screens.maxOfOrNull { it.height }?.toFloat() ?: savedHeight
DpSize(minOf(savedWidth, maxWidth).dp, minOf(savedHeight, maxHeight).dp)
} else {
defaultSize
}
val savedX = saved?.x
val savedY = saved?.y
val position = if (savedX != null && savedY != null && isOnConnectedScreen(savedX, savedY, size.width.value)) {
WindowPosition.Absolute(savedX.dp, savedY.dp)
} else {
WindowCascade.nextPosition(size)
}
return position to size
}
private fun saveBoundsIfFloating(state: WindowState, prefsRepo: WindowPrefsRepository, storageId: String) {
// Minimized windows report a parking position far off-screen on Windows, and a
// maximized/fullscreen window's bounds are the screen's - neither is a position the
// user chose, so only plain floating bounds are remembered.
if (state.isMinimized || state.placement != WindowPlacement.Floating) return
val position = state.position
val size = state.size
if (position !is WindowPosition.Absolute || !size.isSpecified) return
prefsRepo.setWindowBounds(
storageId,
WindowBounds(position.x.value, position.y.value, size.width.value, size.height.value)
)
}
/**
* A WindowState that starts at the window's saved position and size from window_prefs.json
* (if that position is still on a connected monitor), otherwise at the next cascade slot,
* and writes its bounds back whenever the window is moved or resized and when it closes.
*/
@Composable
fun rememberPersistentWindowState(
prefsRepo: WindowPrefsRepository,
storageId: String,
defaultSize: DpSize
): WindowState {
val (initialPosition, initialSize) = remember(storageId) {
resolveInitialPlacement(prefsRepo.boundsFor(storageId), defaultSize)
}
val state = rememberWindowState(position = initialPosition, size = initialSize)
LaunchedEffect(state, storageId) {
snapshotFlow { listOf(state.position, state.size, state.placement, state.isMinimized) }
.collectLatest {
delay(BOUNDS_SAVE_DEBOUNCE_MS)
withContext(Dispatchers.IO) { saveBoundsIfFloating(state, prefsRepo, storageId) }
}
}
// Catches a move/resize made less than BOUNDS_SAVE_DEBOUNCE_MS before the window closed.
DisposableEffect(state, storageId) {
onDispose { saveBoundsIfFloating(state, prefsRepo, storageId) }
}
return state
}
+204
View File
@@ -0,0 +1,204 @@
package ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.WindowState
import java.awt.AWTEvent
import java.awt.GraphicsEnvironment
import java.awt.Rectangle
import java.awt.Toolkit
import java.awt.event.AWTEventListener
import java.awt.event.MouseEvent
import java.util.Base64
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlin.math.floor
import kotlin.math.roundToInt
/**
* Space left over under the last whole line of a window's scrollback, reported by
* MessageScrollback (which already sizes the scrollback itself to whole lines). When a
* resize ends, the window is trimmed by this much so the window itself fits whole lines too.
*/
class LineGap {
@Volatile var gap: Dp = 0.dp
}
val LocalLineGap = staticCompositionLocalOf<LineGap?> { null }
@Composable
fun rememberLineGap(): LineGap = remember { LineGap() }
/**
* After the mouse is let go at the end of a drag or resize:
* - a move snaps the window's top-left corner to the desktop icon grid
* - a resize trims the window to whole lines of scrollback text (keeping whichever edge
* didn't move in place)
* Channel windows are undecorated, so their moves and resizes happen inside the app (the
* title-bar drag area and Compose's own edge resizer) and the mouse release reaches wIRC.
* wIRC main has a normal Windows frame, whose drags Windows handles itself without telling
* the app when the button comes up, so for it ([osFrame] = true) the snap happens once the
* window has sat still for a moment instead.
*/
@Composable
fun WindowSnapping(state: WindowState, lineGap: LineGap, osFrame: Boolean = false) {
if (osFrame) {
LaunchedEffect(state, lineGap) {
var settledPos: WindowPosition = state.position
var settledSize: DpSize = state.size
snapshotFlow { state.position to state.size }.collectLatest {
delay(OS_FRAME_SETTLE_MS)
afterMoveOrResize(state, lineGap, settledPos, settledSize)
settledPos = state.position
settledSize = state.size
}
}
return
}
DisposableEffect(state, lineGap) {
var startPos: WindowPosition? = null
var startSize: DpSize? = null
val listener = AWTEventListener { event ->
val e = event as? MouseEvent ?: return@AWTEventListener
if (e.button != MouseEvent.BUTTON1) return@AWTEventListener
when (e.id) {
MouseEvent.MOUSE_PRESSED -> {
startPos = state.position
startSize = state.size
}
MouseEvent.MOUSE_RELEASED -> {
val p0 = startPos
val s0 = startSize
startPos = null
startSize = null
if (p0 != null && s0 != null) afterMoveOrResize(state, lineGap, p0, s0)
}
}
}
Toolkit.getDefaultToolkit().addAWTEventListener(listener, AWTEvent.MOUSE_EVENT_MASK)
onDispose { Toolkit.getDefaultToolkit().removeAWTEventListener(listener) }
}
}
// How long wIRC main has to sit still after a move/resize before it snaps
private const val OS_FRAME_SETTLE_MS = 400L
private fun afterMoveOrResize(state: WindowState, lineGap: LineGap, p0: WindowPosition, s0: DpSize) {
if (state.isMinimized || state.placement != WindowPlacement.Floating) return
val pos = state.position as? WindowPosition.Absolute ?: return
val size = state.size
if (!size.isSpecified || !s0.isSpecified) return
val start = p0 as? WindowPosition.Absolute
val resized = (size.width - s0.width).value.let { it > 0.5f || it < -0.5f } ||
(size.height - s0.height).value.let { it > 0.5f || it < -0.5f }
if (resized) {
val gap = lineGap.gap
if (gap > 0.5.dp && size.height - gap >= 120.dp) {
// Dragged the top edge (top moved, bottom stayed): keep the bottom where it is
val fromTop = start != null && (pos.y - start.y).value.let { it > 0.5f || it < -0.5f } &&
((pos.y + size.height) - (start.y + s0.height)).value.let { it < 0.5f && it > -0.5f }
state.size = DpSize(size.width, size.height - gap)
if (fromTop) state.position = WindowPosition.Absolute(pos.x, pos.y + gap)
}
return
}
if (start == null || (start.x == pos.x && start.y == pos.y)) return
val (sx, sy) = DesktopGrid.spacing() ?: return
val area = workAreaAt(pos.x.value, pos.y.value)
val x = area.x + ((pos.x.value - area.x) / sx).roundToInt() * sx
val y = area.y + ((pos.y.value - area.y) / sy).roundToInt() * sy
if (x != pos.x.value || y != pos.y.value) state.position = WindowPosition.Absolute(x.dp, y.dp)
}
// The usable area (taskbar excluded) of the screen the window's corner is on, in the same
// logical units as WindowState positions.
private fun workAreaAt(x: Float, y: Float): Rectangle = try {
val ge = GraphicsEnvironment.getLocalGraphicsEnvironment()
val config = ge.screenDevices.map { it.defaultConfiguration }
.firstOrNull { it.bounds.contains(x.toInt(), y.toInt()) } ?: ge.defaultScreenDevice.defaultConfiguration
val b = config.bounds
val i = Toolkit.getDefaultToolkit().getScreenInsets(config)
Rectangle(b.x + i.left, b.y + i.top, b.width - i.left - i.right, b.height - i.top - i.bottom)
} catch (e: Exception) {
Rectangle(0, 0, 1280, 720)
}
/**
* The desktop icon grid's spacing, read from the desktop's own icon view (so it follows the
* icon size and spacing Windows is really using), falling back to the system icon spacing.
* Java can't call Windows directly without extra libraries, so a hidden PowerShell asks once
* at startup, in the background; snapping just skips until the answer is in.
*/
object DesktopGrid {
@Volatile private var cached: Pair<Float, Float>? = null
@Volatile private var started = false
fun spacing(): Pair<Float, Float>? {
if (!started) refreshAsync()
return cached
}
@Synchronized
fun refreshAsync() {
if (started) return
started = true
Thread {
try {
val script = """
Add-Type -TypeDefinition @'
using System; using System.Runtime.InteropServices;
public static class WircGrid {
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern IntPtr FindWindow(string c, string n);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern IntPtr FindWindowEx(IntPtr p, IntPtr a, string c, string n);
[DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr h, int m, IntPtr w, IntPtr l);
[DllImport("user32.dll")] static extern bool SystemParametersInfo(uint a, uint p, out int v, uint i);
public static string Get() {
IntPtr dv = FindWindowEx(FindWindow("Progman", null), IntPtr.Zero, "SHELLDLL_DefView", null);
IntPtr w = IntPtr.Zero;
while (dv == IntPtr.Zero) { w = FindWindowEx(IntPtr.Zero, w, "WorkerW", null); if (w == IntPtr.Zero) break; dv = FindWindowEx(w, IntPtr.Zero, "SHELLDLL_DefView", null); }
IntPtr lv = dv == IntPtr.Zero ? IntPtr.Zero : FindWindowEx(dv, IntPtr.Zero, "SysListView32", null);
if (lv != IntPtr.Zero) { long r = SendMessage(lv, 0x1033, IntPtr.Zero, IntPtr.Zero).ToInt64(); long cx = r & 0xFFFF, cy = (r >> 16) & 0xFFFF; if (cx > 0 && cy > 0) return cx + "," + cy; }
int sx, sy; SystemParametersInfo(0x000D, 0, out sx, 0); SystemParametersInfo(0x0018, 0, out sy, 0);
return sx + "," + sy;
}
}
'@
[WircGrid]::Get()
""".trimIndent()
val encoded = Base64.getEncoder().encodeToString(script.toByteArray(Charsets.UTF_16LE))
val proc = ProcessBuilder("powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded)
.redirectErrorStream(true).start()
val out = proc.inputStream.bufferedReader().readText()
proc.waitFor(30, TimeUnit.SECONDS)
val m = Regex("""(\d+),(\d+)""").findAll(out).lastOrNull() ?: return@Thread
val px = m.groupValues[1].toFloat()
val py = m.groupValues[2].toFloat()
// Physical pixels at the primary screen's scaling -> the logical units windows use
val scale = try {
GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration.defaultTransform.scaleX.toFloat()
} catch (e: Exception) { 1f }.coerceAtLeast(0.5f)
if (px > 8 && py > 8) cached = (px / scale) to (py / scale)
} catch (e: Exception) {
// No grid info: windows just don't snap to it
}
}.apply { isDaemon = true; name = "wIRC desktop grid" }.start()
}
}
/** Whole lines that fit in [height] at [lineHeight], with a little slack for rounding. */
internal fun wholeLines(height: Dp, lineHeight: Dp): Int =
floor((height.value + 0.5f) / lineHeight.value).toInt().coerceAtLeast(1)
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2011 The Quicksand Project Authors (https://github.com/andrew-paglinawan/QuicksandFamily), with Reserved Font Name “Quicksand”.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2023 The Varela Round Project Authors (https://github.com/alefalefalef/Varela-Round-Hebrew/), with Reserved Font Names 'Varela' and ‘Varela Round’.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.