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>
This commit is contained in:
weishaupt
2026-10-10 10:49:55 -04:00
co-authored by Claude Opus 5.5
parent 026e349db8
commit 5bcd1a7bcd
7 changed files with 260 additions and 15 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ plugins {
}
group = "com.tpuahsiew.wirc"
version = "0.1.11"
version = "0.1.12"
// No project-level repositories{} block here: settings.gradle.kts sets
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
@@ -38,7 +38,7 @@ compose.desktop {
appResourcesRootDir.set(project.file("installerResources"))
targetFormats(TargetFormat.Msi, TargetFormat.Exe)
packageName = "wIRC"
packageVersion = "0.1.11"
packageVersion = "0.1.12"
windows {
menuGroup = "wIRC"
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
+7
View File
@@ -51,6 +51,8 @@ fun main() = application {
// Loaded once here (not per-window) so the alias map is truly global - every
// ChannelWindow's command router reads from this same AliasRepository singleton.
AliasRepository.init()
// Ask Windows for the desktop icon grid now, so the first window drag can already snap to it
ui.DesktopGrid.refreshAsync()
val repo = remember { ServerRepository(appRoot) }
val prefsRepo = remember { WindowPrefsRepository(appRoot) }
@@ -85,6 +87,9 @@ fun main() = application {
// ("wirc-main__main"), same as every channel and PM window - see WindowPlacement.kt.
val mainWindowState = rememberPersistentWindowState(prefsRepo, MAIN_BUFFER_KEY.storageId(), DpSize(800.dp, 700.dp))
val mainDisplaySettings by prefsRepo.settingsFor(MAIN_BUFFER_KEY.storageId()).collectAsState()
// Snap to the desktop grid after a move, and to whole lines after a resize
val mainLineGap = ui.rememberLineGap()
ui.WindowSnapping(mainWindowState, mainLineGap, osFrame = true)
// There's no persistent tray/notification surface on this build, so closing the main
// server-list window quits the whole app (and every channel window with it) - the
@@ -98,6 +103,7 @@ fun main() = application {
icon = painterResource("icon.png"),
state = mainWindowState
) {
androidx.compose.runtime.CompositionLocalProvider(ui.LocalLineGap provides mainLineGap) {
MaterialTheme(colorScheme = neutralDarkScheme) {
Surface {
MainScreen(
@@ -113,6 +119,7 @@ fun main() = application {
)
}
}
}
}
openBuffers.forEach { bufferKey ->
+10 -2
View File
@@ -10,6 +10,13 @@ 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
@@ -63,7 +70,7 @@ 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) }
@@ -109,7 +116,8 @@ class WindowPrefsRepository(rootDir: File) {
stored[key] = WindowDisplaySettings(
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
textColorMircIndex = o.optInt("textColorMircIndex", 0),
fontSize = o.optDouble("fontSize", 14.0).toFloat(),
// Sizes saved by the old slider (any value from 10 to 24) round to the nearest picker size
fontSize = pickerFontSize(o.optDouble("fontSize", 14.0).toFloat()),
fontId = o.optString("fontId", "monospace"),
showUserList = o.optBoolean("showUserList", true),
mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(),
+29 -9
View File
@@ -83,6 +83,8 @@ import androidx.compose.ui.window.rememberDialogState
import data.ChannelBuffer
import data.ChannelUser
import data.IrcMessage
import data.MAX_FONT_SIZE
import data.MIN_FONT_SIZE
import data.MessageKind
import data.WindowDisplaySettings
import irc.extractImageUrls
@@ -102,6 +104,7 @@ import java.net.URL
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.floor
import kotlin.math.roundToInt
// Title-bar button fade (nicklist / settings / minimize / close). The buttons sit at
// TITLE_BAR_BUTTON_REST_ALPHA until hovered, then animate to TITLE_BAR_BUTTON_HOVER_ALPHA
@@ -417,7 +420,9 @@ fun WindowScope.ChannelScreen(
Surface {
WindowSettingsPanel(
displaySettings = displaySettings,
textColor = textColor,
// Always white: the dialog is black, so the window's own text
// color (black, say) would make every label invisible here
textColor = Color.White,
onAlphaChange = onAlphaChange,
onTextColorChange = onTextColorChange,
onFontSizeChange = onFontSizeChange,
@@ -859,10 +864,14 @@ fun MessageScrollback(
// real cause was a race between the scroll effect and the LazyColumn's
// own layout catching up to new items - see the scroll effects below.
val snappedHeight = if (lineHeightDp > 0.dp) {
lineHeightDp * floor(maxHeight / lineHeightDp).toInt()
minOf(maxHeight, lineHeightDp * wholeLines(maxHeight, lineHeightDp))
} else {
maxHeight
}
// Tells the window how much space is left under the last whole line, so a resize can
// trim the window itself to fit (see WindowSnapping.kt)
val lineGap = LocalLineGap.current
SideEffect { lineGap?.gap = maxHeight - snappedHeight }
// NOTE: this used to also re-snap the scroll *offset* to the first
// visible item on every resize/font-size change, to fix a partial line
@@ -1441,13 +1450,24 @@ private fun WindowSettingsPanel(
colors = sliderColors
)
Spacer(Modifier.height(4.dp))
Text("Font size", color = textColor, style = MaterialTheme.typography.bodySmall)
Slider(
value = displaySettings.fontSize,
onValueChange = onFontSizeChange,
valueRange = 10f..24f,
colors = sliderColors
)
Text("Font size:", color = textColor, style = MaterialTheme.typography.bodySmall)
// Fixed sizes 10 to 18 (same as QuickClaude's Font size menu), square boxes in a row
val currentSize = displaySettings.fontSize.roundToInt()
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
for (size in MIN_FONT_SIZE..MAX_FONT_SIZE) {
val selected = size == currentSize
Box(
modifier = Modifier
.size(28.dp)
.background(if (selected) Color.White.copy(alpha = 0.25f) else Color.Transparent)
.border(1.dp, if (selected) Color.White else Color.White.copy(alpha = 0.35f))
.clickableNoRipple { onFontSizeChange(size.toFloat()) },
contentAlignment = Alignment.Center
) {
Text(size.toString(), color = textColor, fontSize = 12.sp)
}
}
}
Spacer(Modifier.height(4.dp))
Text("Font:", color = textColor, style = MaterialTheme.typography.bodySmall)
FontPickerList(displaySettings.fontId, textColor, onFontChange)
+6
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
@@ -74,6 +75,9 @@ fun ChannelWindow(
// Restored from window_prefs.json (or cascaded if there's no usable saved position)
// and saved back on move/resize/close - see WindowPlacement.kt.
val windowState = rememberPersistentWindowState(prefsRepo, bufferKey.storageId(), DpSize(800.dp, 600.dp))
// Snap to the desktop grid after a move, and to whole lines after a resize
val lineGap = rememberLineGap()
WindowSnapping(windowState, lineGap)
// undecorated + transparent trade the native OS title bar for real per-pixel window
// transparency (Windows only honors transparency on undecorated windows) - ChannelScreen
@@ -89,6 +93,7 @@ fun ChannelWindow(
transparent = true,
resizable = true
) {
CompositionLocalProvider(LocalLineGap provides lineGap) {
MaterialTheme(colorScheme = colorScheme) {
ChannelScreen(
windowState = windowState,
@@ -198,5 +203,6 @@ fun ChannelWindow(
}
)
}
}
}
}
+2 -2
View File
@@ -205,9 +205,9 @@ fun MainScreen(
MaterialTheme(colorScheme = colorScheme) {
Surface {
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))
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)