3 Commits
Author SHA1 Message Date
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
8 changed files with 455 additions and 35 deletions
+4 -2
View File
@@ -6,7 +6,7 @@ plugins {
} }
group = "com.tpuahsiew.wirc" group = "com.tpuahsiew.wirc"
version = "0.1.11" version = "0.1.14"
// No project-level repositories{} block here: settings.gradle.kts sets // No project-level repositories{} block here: settings.gradle.kts sets
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already // dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
@@ -22,6 +22,8 @@ dependencies {
implementation(compose.materialIconsExtended) implementation(compose.materialIconsExtended)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1")
implementation("org.json:json:20240303") implementation("org.json:json:20240303")
// Native Windows calls that keep wIRC's windows off the taskbar (see AppTray.kt)
implementation("net.java.dev.jna:jna-platform:5.14.0")
} }
kotlin { kotlin {
@@ -38,7 +40,7 @@ compose.desktop {
appResourcesRootDir.set(project.file("installerResources")) appResourcesRootDir.set(project.file("installerResources"))
targetFormats(TargetFormat.Msi, TargetFormat.Exe) targetFormats(TargetFormat.Msi, TargetFormat.Exe)
packageName = "wIRC" packageName = "wIRC"
packageVersion = "0.1.11" packageVersion = "0.1.14"
windows { windows {
menuGroup = "wIRC" menuGroup = "wIRC"
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a" upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
+33 -7
View File
@@ -23,6 +23,7 @@ import data.storageId
import irc.ConnectionManager import irc.ConnectionManager
import ui.ChannelWindow import ui.ChannelWindow
import ui.MainScreen import ui.MainScreen
import ui.TrayWindowBehavior
import ui.rememberPersistentWindowState import ui.rememberPersistentWindowState
import java.io.File import java.io.File
@@ -51,6 +52,8 @@ fun main() = application {
// Loaded once here (not per-window) so the alias map is truly global - every // Loaded once here (not per-window) so the alias map is truly global - every
// ChannelWindow's command router reads from this same AliasRepository singleton. // ChannelWindow's command router reads from this same AliasRepository singleton.
AliasRepository.init() AliasRepository.init()
// Ask Windows for the desktop icon grid now, so the first window drag can already snap to it
ui.DesktopGrid.refreshAsync()
val repo = remember { ServerRepository(appRoot) } val repo = remember { ServerRepository(appRoot) }
val prefsRepo = remember { WindowPrefsRepository(appRoot) } val prefsRepo = remember { WindowPrefsRepository(appRoot) }
@@ -85,19 +88,39 @@ fun main() = application {
// ("wirc-main__main"), same as every channel and PM window - see WindowPlacement.kt. // ("wirc-main__main"), same as every channel and PM window - see WindowPlacement.kt.
val mainWindowState = rememberPersistentWindowState(prefsRepo, MAIN_BUFFER_KEY.storageId(), DpSize(800.dp, 700.dp)) val mainWindowState = rememberPersistentWindowState(prefsRepo, MAIN_BUFFER_KEY.storageId(), DpSize(800.dp, 700.dp))
val mainDisplaySettings by prefsRepo.settingsFor(MAIN_BUFFER_KEY.storageId()).collectAsState() val mainDisplaySettings by prefsRepo.settingsFor(MAIN_BUFFER_KEY.storageId()).collectAsState()
// Snap to the desktop grid after a move, and to whole lines after a resize
val mainLineGap = ui.rememberLineGap()
ui.WindowSnapping(mainWindowState, mainLineGap, osFrame = true)
// There's no persistent tray/notification surface on this build, so closing the main // wIRC lives in the tray: no window is on the taskbar, and minimizing any window hides
// server-list window quits the whole app (and every channel window with it) - the // all of them (see TrayWindowBehavior). Hidden windows stay composed and connected, so
// same "closing the primary window ends the session" convention most desktop chat // nothing is lost while they're away; showing them again puts each back where it was.
// apps without a tray icon follow. // A window opened while hidden (e.g. a new PM) stays hidden until they're shown.
var windowsShown by remember { mutableStateOf(true) }
val hideAll: () -> Unit = { windowsShown = false }
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 = { windowsShown = !windowsShown },
onShow = { windowsShown = true },
onHide = hideAll,
onExit = shutdownApp
)
// Closing the main server-list window quits the whole app (and every channel window
// with it) - the tray's Exit does the same, and also disconnects first.
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
// The unread count shows in the taskbar too, so new server messages are visible visible = windowsShown,
// even when the main window is behind other windows. title = mainTitle,
title = if (mainUnread > 0) "wIRC - Main ($mainUnread new)" else "wIRC - Main",
icon = painterResource("icon.png"), icon = painterResource("icon.png"),
state = mainWindowState state = mainWindowState
) { ) {
TrayWindowBehavior(mainWindowState, hideAll)
androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) {
MaterialTheme(colorScheme = neutralDarkScheme) { MaterialTheme(colorScheme = neutralDarkScheme) {
Surface { Surface {
MainScreen( MainScreen(
@@ -114,6 +137,7 @@ fun main() = application {
} }
} }
} }
}
openBuffers.forEach { bufferKey -> openBuffers.forEach { bufferKey ->
key(bufferKey) { key(bufferKey) {
@@ -121,6 +145,8 @@ fun main() = application {
bufferKey = bufferKey, bufferKey = bufferKey,
prefsRepo = prefsRepo, prefsRepo = prefsRepo,
colorScheme = neutralDarkScheme, colorScheme = neutralDarkScheme,
visible = windowsShown,
onHideAll = hideAll,
onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey }, onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey },
onCloseSelf = { openBuffers = openBuffers - bufferKey }, onCloseSelf = { openBuffers = openBuffers - bufferKey },
// openBuffers is read when /quit runs, so every window open at that moment // openBuffers is read when /quit runs, so every window open at that moment
+10 -2
View File
@@ -10,6 +10,13 @@ import java.nio.file.Files
import java.nio.file.StandardCopyOption import java.nio.file.StandardCopyOption
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
// Font sizes offered in the settings dialog (whole sizes only, same range as QuickClaude)
const val MIN_FONT_SIZE = 10
const val MAX_FONT_SIZE = 18
private fun pickerFontSize(size: Float): Float =
Math.round(size).coerceIn(MIN_FONT_SIZE, MAX_FONT_SIZE).toFloat()
data class WindowDisplaySettings( data class WindowDisplaySettings(
val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque
val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white
@@ -63,7 +70,7 @@ class WindowPrefsRepository(rootDir: File) {
fun setTextColorMircIndex(bufferKeyId: String, index: Int) = update(bufferKeyId) { it.copy(textColorMircIndex = index) } fun setTextColorMircIndex(bufferKeyId: String, index: Int) = update(bufferKeyId) { it.copy(textColorMircIndex = index) }
fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = fontSize) } fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = pickerFontSize(fontSize)) }
fun setFontId(bufferKeyId: String, fontId: String) = update(bufferKeyId) { it.copy(fontId = fontId) } fun setFontId(bufferKeyId: String, fontId: String) = update(bufferKeyId) { it.copy(fontId = fontId) }
@@ -109,7 +116,8 @@ class WindowPrefsRepository(rootDir: File) {
stored[key] = WindowDisplaySettings( stored[key] = WindowDisplaySettings(
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(), backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
textColorMircIndex = o.optInt("textColorMircIndex", 0), textColorMircIndex = o.optInt("textColorMircIndex", 0),
fontSize = o.optDouble("fontSize", 14.0).toFloat(), // Sizes saved by the old slider (any value from 10 to 24) round to the nearest picker size
fontSize = pickerFontSize(o.optDouble("fontSize", 14.0).toFloat()),
fontId = o.optString("fontId", "monospace"), fontId = o.optString("fontId", "monospace"),
showUserList = o.optBoolean("showUserList", true), showUserList = o.optBoolean("showUserList", true),
mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(), mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(),
+126
View File
@@ -0,0 +1,126 @@
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 toggles all windows; right click opens Show / Hide / 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,
onHide: () -> Unit,
onExit: () -> Unit
) {
val currentToggle by rememberUpdatedState(onToggle)
val currentShow by rememberUpdatedState(onShow)
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("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 (the title-bar button, Win+D, etc.) into hiding every wIRC window to
* the tray: with no taskbar button, a minimized window would have no way back.
*/
@Composable
fun FrameWindowScope.TrayWindowBehavior(state: WindowState, onHideAll: () -> Unit) {
remember(window) { hideFromTaskbar(window) }
val currentHideAll by rememberUpdatedState(onHideAll)
LaunchedEffect(state) {
snapshotFlow { state.isMinimized }.collect { minimized ->
if (minimized) {
currentHideAll()
// 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))
}
}
+55 -13
View File
@@ -61,6 +61,7 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@@ -78,11 +79,12 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogWindow import androidx.compose.ui.window.DialogWindow
import androidx.compose.ui.window.WindowScope import androidx.compose.ui.window.WindowScope
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.rememberDialogState import androidx.compose.ui.window.rememberDialogState
import data.ChannelBuffer import data.ChannelBuffer
import data.ChannelUser import data.ChannelUser
import data.IrcMessage import data.IrcMessage
import data.MAX_FONT_SIZE
import data.MIN_FONT_SIZE
import data.MessageKind import data.MessageKind
import data.WindowDisplaySettings import data.WindowDisplaySettings
import irc.extractImageUrls import irc.extractImageUrls
@@ -102,6 +104,7 @@ import java.net.URL
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.math.floor import kotlin.math.floor
import kotlin.math.roundToInt
// Title-bar button fade (nicklist / settings / minimize / close). The buttons sit at // Title-bar button fade (nicklist / settings / minimize / close). The buttons sit at
// TITLE_BAR_BUTTON_REST_ALPHA until hovered, then animate to TITLE_BAR_BUTTON_HOVER_ALPHA // TITLE_BAR_BUTTON_REST_ALPHA until hovered, then animate to TITLE_BAR_BUTTON_HOVER_ALPHA
@@ -180,7 +183,7 @@ private fun copyToClipboardSafely(scope: CoroutineScope, text: String) {
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable @Composable
fun WindowScope.ChannelScreen( fun WindowScope.ChannelScreen(
windowState: WindowState, onMinimize: () -> Unit,
onCloseWindow: () -> Unit, onCloseWindow: () -> Unit,
channelName: String, channelName: String,
buffer: ChannelBuffer?, buffer: ChannelBuffer?,
@@ -379,7 +382,7 @@ fun WindowScope.ChannelScreen(
FadingTitleBarButton(onClick = { showSettings = !showSettings }) { FadingTitleBarButton(onClick = { showSettings = !showSettings }) {
Icon(Icons.Default.Settings, contentDescription = "Window settings", tint = textColor, modifier = Modifier.size(16.dp)) Icon(Icons.Default.Settings, contentDescription = "Window settings", tint = textColor, modifier = Modifier.size(16.dp))
} }
FadingTitleBarButton(onClick = { windowState.isMinimized = true }) { FadingTitleBarButton(onClick = onMinimize) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(width = 10.dp, height = 2.dp) .size(width = 10.dp, height = 2.dp)
@@ -417,7 +420,9 @@ fun WindowScope.ChannelScreen(
Surface { Surface {
WindowSettingsPanel( WindowSettingsPanel(
displaySettings = displaySettings, displaySettings = displaySettings,
textColor = textColor, // Always white: the dialog is black, so the window's own text
// color (black, say) would make every label invisible here
textColor = Color.White,
onAlphaChange = onAlphaChange, onAlphaChange = onAlphaChange,
onTextColorChange = onTextColorChange, onTextColorChange = onTextColorChange,
onFontSizeChange = onFontSizeChange, onFontSizeChange = onFontSizeChange,
@@ -859,10 +864,14 @@ fun MessageScrollback(
// real cause was a race between the scroll effect and the LazyColumn's // real cause was a race between the scroll effect and the LazyColumn's
// own layout catching up to new items - see the scroll effects below. // own layout catching up to new items - see the scroll effects below.
val snappedHeight = if (lineHeightDp > 0.dp) { val snappedHeight = if (lineHeightDp > 0.dp) {
lineHeightDp * floor(maxHeight / lineHeightDp).toInt() minOf(maxHeight, lineHeightDp * wholeLines(maxHeight, lineHeightDp))
} else { } else {
maxHeight maxHeight
} }
// Tells the window how much space is left under the last whole line, so a resize can
// trim the window itself to fit (see WindowSnapping.kt)
val lineGap = LocalLineGap.current
SideEffect { lineGap?.gap = maxHeight - snappedHeight }
// NOTE: this used to also re-snap the scroll *offset* to the first // NOTE: this used to also re-snap the scroll *offset* to the first
// visible item on every resize/font-size change, to fix a partial line // visible item on every resize/font-size change, to fix a partial line
@@ -1050,7 +1059,10 @@ fun MessageScrollback(
} }
} }
Column(modifier = Modifier.fillMaxWidth()) { // Every row is kept a whole number of lines tall (nearest whole line, to absorb
// the pixel or two a wrapped message can be off by), so the line snapping above
// stays lined up all the way to the top of the window instead of drifting.
Column(modifier = Modifier.fillMaxWidth().snapHeightToLines(lineHeightPx, roundUp = false)) {
// Timestamp + nick are one text and the body another, both BasicText with // Timestamp + nick are one text and the body another, both BasicText with
// the same explicit style - BasicText never merges the ambient LocalTextStyle, // the same explicit style - BasicText never merges the ambient LocalTextStyle,
// so neither picks up MaterialTheme's bodyLarge 24sp lineHeight (which once // so neither picks up MaterialTheme's bodyLarge 24sp lineHeight (which once
@@ -1076,6 +1088,10 @@ fun MessageScrollback(
} }
if (displaySettings.showImagePreviews) { if (displaySettings.showImagePreviews) {
extractImageUrls(msg.rawText).forEach { url -> extractImageUrls(msg.rawText).forEach { url ->
// A thumbnail can be any height, so its slot is rounded UP to whole lines
// (never cropping the picture); otherwise every line above it in the
// window would sit a fraction of a line off and show cut in half at the top.
Box(modifier = Modifier.snapHeightToLines(lineHeightPx, roundUp = true)) {
UrlImagePreview( UrlImagePreview(
url = url, url = url,
modifier = Modifier modifier = Modifier
@@ -1092,11 +1108,26 @@ fun MessageScrollback(
} }
} }
} }
}
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
} }
} }
} }
// Reports this element's height as a whole number of text lines ([linePx] each): rounded up
// for image slots (the picture is never cut), to the nearest line for text rows (a wrapped
// message can measure a pixel or two off an exact multiple). Content is drawn from the top.
private fun Modifier.snapHeightToLines(linePx: Int, roundUp: Boolean): Modifier = layout { measurable, constraints ->
val placeable = measurable.measure(constraints.copy(minHeight = 0))
val h = placeable.height
val snapped = when {
linePx <= 0 || h == 0 -> h
roundUp -> ((h + linePx - 1) / linePx) * linePx
else -> (((h + linePx / 2) / linePx).coerceAtLeast(1)) * linePx
}.coerceIn(constraints.minHeight, constraints.maxHeight.coerceAtLeast(constraints.minHeight))
layout(placeable.width, snapped) { placeable.place(0, 0) }
}
// Which of a row's two texts a TextPos is in: the timestamp/nick prefix, or the body. // Which of a row's two texts a TextPos is in: the timestamp/nick prefix, or the body.
private const val PART_PREFIX = 0 private const val PART_PREFIX = 0
private const val PART_BODY = 1 private const val PART_BODY = 1
@@ -1441,13 +1472,24 @@ private fun WindowSettingsPanel(
colors = sliderColors colors = sliderColors
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text("Font size", color = textColor, style = MaterialTheme.typography.bodySmall) Text("Font size:", color = textColor, style = MaterialTheme.typography.bodySmall)
Slider( // Fixed sizes 10 to 18 (same as QuickClaude's Font size menu), square boxes in a row
value = displaySettings.fontSize, val currentSize = displaySettings.fontSize.roundToInt()
onValueChange = onFontSizeChange, Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
valueRange = 10f..24f, for (size in MIN_FONT_SIZE..MAX_FONT_SIZE) {
colors = sliderColors val selected = size == currentSize
) Box(
modifier = Modifier
.size(28.dp)
.background(if (selected) Color.White.copy(alpha = 0.25f) else Color.Transparent)
.border(1.dp, if (selected) Color.White else Color.White.copy(alpha = 0.35f))
.clickableNoRipple { onFontSizeChange(size.toFloat()) },
contentAlignment = Alignment.Center
) {
Text(size.toString(), color = textColor, fontSize = 12.sp)
}
}
}
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text("Font:", color = textColor, style = MaterialTheme.typography.bodySmall) Text("Font:", color = textColor, style = MaterialTheme.typography.bodySmall)
FontPickerList(displaySettings.fontId, textColor, onFontChange) FontPickerList(displaySettings.fontId, textColor, onFontChange)
+13 -1
View File
@@ -3,6 +3,7 @@ package ui
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@@ -45,6 +46,8 @@ fun ChannelWindow(
bufferKey: BufferKey, bufferKey: BufferKey,
prefsRepo: WindowPrefsRepository, prefsRepo: WindowPrefsRepository,
colorScheme: ColorScheme, colorScheme: ColorScheme,
visible: Boolean,
onHideAll: () -> Unit,
onOpenBuffer: (BufferKey) -> Unit, onOpenBuffer: (BufferKey) -> Unit,
onCloseSelf: () -> Unit, onCloseSelf: () -> Unit,
onDisconnectServer: () -> Unit onDisconnectServer: () -> Unit
@@ -74,14 +77,20 @@ fun ChannelWindow(
// Restored from window_prefs.json (or cascaded if there's no usable saved position) // Restored from window_prefs.json (or cascaded if there's no usable saved position)
// and saved back on move/resize/close - see WindowPlacement.kt. // and saved back on move/resize/close - see WindowPlacement.kt.
val windowState = rememberPersistentWindowState(prefsRepo, bufferKey.storageId(), DpSize(800.dp, 600.dp)) val windowState = rememberPersistentWindowState(prefsRepo, bufferKey.storageId(), DpSize(800.dp, 600.dp))
// Snap to the desktop grid after a move, and to whole lines after a resize
val lineGap = rememberLineGap()
WindowSnapping(windowState, lineGap)
// undecorated + transparent trade the native OS title bar for real per-pixel window // undecorated + transparent trade the native OS title bar for real per-pixel window
// transparency (Windows only honors transparency on undecorated windows) - ChannelScreen // transparency (Windows only honors transparency on undecorated windows) - ChannelScreen
// supplies its own title bar with minimize/close/drag/resize to make up for what the // supplies its own title bar with minimize/close/drag/resize to make up for what the
// OS chrome would otherwise provide. No Surface wrapper here (M3 Surface paints an // OS chrome would otherwise provide. No Surface wrapper here (M3 Surface paints an
// opaque background) - ChannelScreen already paints its own alpha-aware background. // opaque background) - ChannelScreen already paints its own alpha-aware background.
// Hidden to the tray along with every other wIRC window when [visible] is false - the
// window and its composition stay alive, so its buffer keeps filling while hidden.
Window( Window(
onCloseRequest = onCloseSelf, onCloseRequest = onCloseSelf,
visible = visible,
title = windowTitle, title = windowTitle,
icon = painterResource("icon.png"), icon = painterResource("icon.png"),
state = windowState, state = windowState,
@@ -89,9 +98,11 @@ fun ChannelWindow(
transparent = true, transparent = true,
resizable = true resizable = true
) { ) {
TrayWindowBehavior(windowState, onHideAll)
CompositionLocalProvider(LocalLineGap provides lineGap) {
MaterialTheme(colorScheme = colorScheme) { MaterialTheme(colorScheme = colorScheme) {
ChannelScreen( ChannelScreen(
windowState = windowState, onMinimize = onHideAll,
onCloseWindow = onCloseSelf, onCloseWindow = onCloseSelf,
channelName = windowTitle, channelName = windowTitle,
buffer = buffer, buffer = buffer,
@@ -199,4 +210,5 @@ fun ChannelWindow(
) )
} }
} }
}
} }
+2 -2
View File
@@ -205,9 +205,9 @@ fun MainScreen(
MaterialTheme(colorScheme = colorScheme) { MaterialTheme(colorScheme = colorScheme) {
Surface { Surface {
Column(modifier = Modifier.fillMaxSize().padding(8.dp).verticalScroll(rememberScrollState())) { Column(modifier = Modifier.fillMaxSize().padding(8.dp).verticalScroll(rememberScrollState())) {
Text("Server messages font:", color = textColor, style = MaterialTheme.typography.bodySmall) Text("Server messages font:", color = Color.White, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
FontPickerList(displaySettings.fontId, textColor, onFontChange) FontPickerList(displaySettings.fontId, Color.White, onFontChange)
} }
} }
} }
+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)