diff --git a/build.gradle.kts b/build.gradle.kts index aef1b37..5e8436f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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" diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 77229b3..846b529 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -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 -> diff --git a/src/main/kotlin/data/WindowPrefsRepository.kt b/src/main/kotlin/data/WindowPrefsRepository.kt index b844847..81c7fd4 100644 --- a/src/main/kotlin/data/WindowPrefsRepository.kt +++ b/src/main/kotlin/data/WindowPrefsRepository.kt @@ -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(), diff --git a/src/main/kotlin/ui/ChannelScreen.kt b/src/main/kotlin/ui/ChannelScreen.kt index a4580d6..c95ff05 100644 --- a/src/main/kotlin/ui/ChannelScreen.kt +++ b/src/main/kotlin/ui/ChannelScreen.kt @@ -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) diff --git a/src/main/kotlin/ui/ChannelWindow.kt b/src/main/kotlin/ui/ChannelWindow.kt index 5aa6998..3a9d95c 100644 --- a/src/main/kotlin/ui/ChannelWindow.kt +++ b/src/main/kotlin/ui/ChannelWindow.kt @@ -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( } ) } + } } } diff --git a/src/main/kotlin/ui/MainScreen.kt b/src/main/kotlin/ui/MainScreen.kt index d1fdc5e..112c762 100644 --- a/src/main/kotlin/ui/MainScreen.kt +++ b/src/main/kotlin/ui/MainScreen.kt @@ -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) } } } diff --git a/src/main/kotlin/ui/WindowSnapping.kt b/src/main/kotlin/ui/WindowSnapping.kt new file mode 100644 index 0000000..5d35f1f --- /dev/null +++ b/src/main/kotlin/ui/WindowSnapping.kt @@ -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 { 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? = null + @Volatile private var started = false + + fun spacing(): Pair? { + 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)