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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
242d7a7c42
commit
807903fe0f
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user