diff --git a/AGENTS.md b/AGENTS.md index e44aee12c..cc463b92e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,41 @@ fine and a clean build re-arms it. Note that querying windows via `osascript`/Sy macOS `dlopen` the accessibility bundles into the target - that adds a waiter on the very same lock and reports `0 windows` as a false negative. Use `jcmd`/`sample` instead. +### AWT cannot make a decorated frame transparent (measured) + +Do not spend an afternoon rediscovering this. AWT allocates an alpha-capable backing store only for +windows it considers translucent, and refuses that for decorated frames - +`IllegalComponentStateException: The frame is decorated`, on both `setBackground(alpha<255)` and +`setOpacity`, before and after the window is shown. Forcing the `NSWindow` non-opaque underneath is +not enough either: measured through the peer, `CPlatformWindow.setOpaque(false)` runs and the window +reports `isOpaque = NO`, and alpha still composites onto black because the surface has no alpha +channel. The same alpha-0 fill in an **undecorated** window is see-through. + +macOS itself allows it, which is how Terminal.app is transparent with traffic lights - it is AWT's +window that cannot be. So transparency belongs to the undecorated path only, which is exactly what +`useNativeTitleBar = false` selects. See `compose-ui/.../window/NativeTitleBarStyle.kt`. + +One consequence rides along, and it is why the app no longer follows the system appearance: with +`apple.awt.transparentTitleBar` the title text sits over OUR background, but AppKit still picks that +text's colour from the window appearance. Following the system therefore guarantees an unreadable +title whenever the two disagree - confirmed by hand, a light system appearance drew a near-black +title on the near-black default background. `nativeTitleBarAppearance` derives +`apple.awt.application.appearance` from the terminal background instead, which fixes both +directions. It has to be applied before AWT boots, and it is app-wide, so other AWT chrome (the +native context menus) follows the terminal background too. + +### OSC 1 names the TAB, OSC 2 names the WINDOW + +xterm's split, and it is load-bearing across `TabController`, `TabbedTerminal` and `ProperTerminal`. +Do not fold them into one field: apps set them to different strings deliberately (oh-my-zsh emits a +short OSC 1 and a long OSC 2 back to back from `precmd`), so merging makes the tab label depend on +which arrived last. OSC 0 sets both. + +The window title resolves `resolveWindowTitle`: a Rename… custom title, then OSC 2, then the tab's +own title. The OSC 2 slot is cleared at prompt start so a program that set one stops naming the +window after it exits - which needs OSC 133, so it does not happen inside tmux/screen or without the +shell integration. + ### Emoji Rendering Skia ignores variation selectors (U+FE0F). Peek-ahead to detect, switch to `FontFamily.Default`, render as unit. @@ -111,6 +146,10 @@ Located in: `compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/shell/S - `bossterm-core-mpp/src/jvmMain/kotlin/com/bossterm/terminal/model/TerminalTextBuffer.kt` - `compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/pool/IncrementalSnapshotBuilder.kt` +**Window** +- `compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt` + (full window content, the title bar inset, and the transparency finding above) + **Components** - `compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt` - `compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/EmbeddableTerminal.kt` diff --git a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt index dfce38070..c4bbf203f 100644 --- a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt +++ b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt @@ -2,6 +2,8 @@ package ai.rever.bossterm.app import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -32,6 +34,9 @@ import ai.rever.bossterm.compose.shell.ShellCustomizationUtils import ai.rever.bossterm.compose.update.UpdateBanner import ai.rever.bossterm.compose.update.UpdateManager import ai.rever.bossterm.compose.window.CustomTitleBar +import ai.rever.bossterm.compose.window.applyFullWindowContent +import ai.rever.bossterm.compose.window.nativeTitleBarAppearance +import ai.rever.bossterm.compose.window.titleBarInset import ai.rever.bossterm.compose.window.GlobalHotKeyManager import ai.rever.bossterm.compose.window.HotKeyConfig import ai.rever.bossterm.compose.window.WindowManager @@ -75,6 +80,17 @@ fun main(args: Array) { return } + // Window appearance, derived from OUR background rather than the system's. Must be here: + // apple.awt.application.appearance is read once when AWT initializes, so this has to beat the + // deep-link handler below (it touches java.awt.Desktop) as well as any window. See + // nativeTitleBarAppearance for why following the system is not an option once the title bar is + // transparent. Settings are plain file + JSON at this point, no toolkit involved. + SettingsManager.instance.settings.value.let { s -> + nativeTitleBarAppearance(s.useNativeTitleBar, s.defaultBackground)?.let { + System.setProperty("apple.awt.application.appearance", it) + } + } + // Configure GPU rendering (must be before any Skiko/Compose initialization) configureGpuRendering() @@ -284,6 +300,30 @@ fun main(args: Array) { } } ) { + // Native title bar styling: let the app's background run under the title bar so + // the traffic lights sit on it, instead of a system-painted strip above the + // content. Only meaningful on the native path - the custom title bar already + // owns that area. `this@Window.window` because the loop variable above shadows + // FrameWindowScope.window. + // Split deliberately: whether the content WILL run under the title bar is a + // pure predicate, so the inset below is right on the very first frame, while + // the client-property write that makes it so is a mutation and belongs in the + // effect phase. (A composition can be discarded or re-run; remember's + // initializer is not guaranteed to belong to the one that gets applied.) + val fullWindowContent = + remember { useNativeTitleBar && ShellCustomizationUtils.isMacOS() } + // Starts optimistic so the inset is right on frame one, then takes the actual + // result: applyFullWindowContent declines a window it cannot style, and the + // inset has to follow it or we reserve 28dp for a title bar that stayed where + // it was. ComposeWindow is a JFrame so the decline path should never fire - + // this is what makes that a fact about the code rather than a hope. + var styleApplied by remember { mutableStateOf(fullWindowContent) } + LaunchedEffect(fullWindowContent) { + if (fullWindowContent) { + styleApplied = applyFullWindowContent(this@Window.window) + } + } + // Update manager state val updateManager = remember { UpdateManager.instance } val updateState by updateManager.updateState.collectAsState() @@ -729,6 +769,11 @@ fun main(args: Array) { windowState.placement == WindowPlacement.Maximized val cornerRadius = if (useNativeTitleBar || isFullscreenOrMaximized) 0.dp else 20.dp + // One value for every site that has to stay clear of the title bar. See + // titleBarInset for why it is Fullscreen-only, why Maximized still insets, and + // why placement is trusted for this. + val topInset = titleBarInset(styleApplied, windowState.placement) + // Load background image if set val backgroundImage = remember(windowSettings.backgroundImagePath) { if (windowSettings.backgroundImagePath.isNotEmpty()) { @@ -810,6 +855,13 @@ fun main(args: Array) { } Column(modifier = Modifier.fillMaxSize()) { + // The content pane now extends under the title bar, so reserve its + // height - otherwise the tab bar would sit beneath the traffic + // lights and be unclickable. The app's background already paints + // through this strip, which is the point. Zero in fullscreen; see + // titleBarInset. + Spacer(modifier = Modifier.height(topInset)) + // Custom title bar (only when not using native title bar) if (!useNativeTitleBar) { CustomTitleBar( @@ -905,10 +957,14 @@ fun main(args: Array) { // Hotkey hint overlay (top-right corner, like iTerm2) // Shows for native title bar; custom title bar shows it in the title bar itself if (useNativeTitleBar && globalHotkeyHint != null) { + // This Box is a sibling of the Column above, so it is NOT covered + // by that Column's title bar spacer - it anchors to the frame top + // and has to apply the same inset itself, or it renders inside the + // title bar strip and crowds the centred window title. Box( modifier = Modifier .align(Alignment.TopEnd) - .padding(top = 8.dp, end = 12.dp) + .padding(top = topInset + 8.dp, end = 12.dp) ) { Text( text = globalHotkeyHint, diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt index 41f97fc0b..d5de559b4 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt @@ -129,6 +129,17 @@ class ComposeTerminalDisplay : TerminalDisplay { val bellTrigger: State = _bellTrigger val progressState: State = _progressState val progressValue: State = _progressValue + /** + * The app's OSC 2 window title. + * + * Empty is a RESET, not merely "nothing yet": TabController clears it at each prompt start + * so a title set by a program that has since exited stops naming the window. Consumers should + * fall back to something of their own rather than showing a blank - see + * `resolveWindowTitle` in TabbedTerminal, which falls back to the tab title. + * + * The reset needs OSC 133, so it does not happen inside tmux/screen or in a session with no + * shell integration; there a title outlives the program that set it, as it always has. + */ val windowTitleFlow: StateFlow = _windowTitle.asStateFlow() val iconTitleFlow: StateFlow = _iconTitle.asStateFlow() diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt index e3d4228cf..3afb10c70 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt @@ -1626,16 +1626,40 @@ fun TabbedTerminal( val activeTab = tabController.tabs[tabController.activeTabIndex] val splitState = getOrCreateSplitState(activeTab) - // Update the OS window title from the FOCUSED pane's window title. + // Update the OS window title from the FOCUSED pane's tab title. // Re-subscribe when focus moves between split panes so the window title // follows the active pane instead of always the root/first pane. + // + // Precedence, highest first: a Rename… custom title, then the app's OSC 2 window + // title, then the tab's own title. + // + // OSC 2 alone is not enough - most shells never emit it, which is what left the + // window showing its initial "BossTerm" while the tab bar beside it tracked the + // directory. session.title is the resolved fallback every other surface already + // uses: the cwd label, with an app's OSC 0/1 icon title mirrored in and re-asserted + // on each fresh prompt. Sharing that source is what keeps the title bar and the tab + // agreeing whenever nothing more specific has been said. + // + // But OSC 2 still wins when an app does set it, because that is what it is FOR + // (xterm's split: OSC 1 names the tab, OSC 2 names the window) and some apps + // deliberately give the window a longer string than the tab. TabController clears it + // at each PROMPT start, so it reverts once the app that set it exits - wherever OSC + // 133 reaches, which is not inside tmux/screen or without the shell integration. LaunchedEffect(activeTab, splitState.focusedPaneId) { val focused = splitState.getFocusedSession() ?: activeTab - focused.display.windowTitleFlow.collect { newTitle -> - if (newTitle.isNotEmpty()) { - onWindowTitleChange(newTitle) + snapshotFlow { focused.customTitle.value to focused.title.value } + .combine(focused.display.windowTitleFlow) { (custom, tabTitle), windowTitle -> + resolveWindowTitle(custom, windowTitle, tabTitle) + } + // combine re-emits when EITHER side changes, and a rename writes customTitle + // and title as two separate snapshot writes. window.title bottoms out in an + // AppKit call, so only pass on actual changes. + .distinctUntilChanged() + .collect { newTitle -> + if (newTitle.isNotEmpty()) { + onWindowTitleChange(newTitle) + } } - } } // Daemon-mirrored pane: route split/close through the daemon instead of touching the diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/WindowTitle.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/WindowTitle.kt new file mode 100644 index 000000000..581aa6be1 --- /dev/null +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/WindowTitle.kt @@ -0,0 +1,39 @@ +package ai.rever.bossterm.compose + +/** + * Which of the three candidate titles the OS window should show. + * + * Precedence, highest first: a Rename… custom title, then the app's OSC 2 window title, then the + * tab's own title. See the call site for why each one is where it is. + * + * @param osc2 the OSC 2 window title, where empty means "reset, fall back" rather than "blank" - + * TabController clears it at each prompt start so an exited program stops naming the window. + * Blank counts as empty for the same reason a blank rename does: a whitespace title would + * otherwise win and leave the window looking nameless. + * @return empty only when every candidate is empty, which the caller suppresses rather than + * showing a nameless window. + */ +internal fun resolveWindowTitle(custom: String?, osc2: String, tabTitle: String): String = + // ifBlank, not just null: both rename paths normalise blank to null today, but a whitespace + // custom title would otherwise win and blank the window rather than falling through. + custom?.ifBlank { null } ?: osc2.ifBlank { tabTitle } + +/** + * The title a command-completion notification carries. + * + * Same precedence as the window title, so the two agree on which session finished - which is the + * notification's entire job, since it only fires while the window is UNFOCUSED and several tabs all + * announcing the app name says nothing. + * + * @param tabTitle the tab's resolved title. This is the one that makes the fallback useful: it is + * re-asserted at every prompt, mirrors an app's OSC 1, and otherwise reads the cwd - so it + * neither goes stale like a raw OSC 1 slot (which nothing resets, and would still say "vim" + * long after vim exited) nor collapses to the app name on a shell that sets no title at all. + * @param fallback used only when there is genuinely nothing to say. + */ +internal fun notificationTitle( + custom: String?, + osc2: String, + tabTitle: String, + fallback: String, +): String = resolveWindowTitle(custom, osc2, tabTitle).ifBlank { fallback } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt index ba85ecfd4..9685c58a0 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt @@ -169,6 +169,13 @@ data class TerminalSettings( * When true: Native macOS title bar, proper fullscreen, but no transparency. * When false: Custom title bar, transparency works, but no true fullscreen. * Changing this requires app restart to take effect. + * + * "No transparency" is an AWT restriction, not a macOS one - see + * `applyFullWindowContent` in `window/NativeTitleBarStyle.kt`, which records what was measured. + * Short version: AWT gives an alpha-capable backing store only to windows it treats as + * translucent and refuses that for decorated frames, and forcing the NSWindow non-opaque + * underneath does not help because the surface has no alpha channel. Do not spend an + * afternoon rediscovering it. */ val useNativeTitleBar: Boolean = true, diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/sections/VisualSettingsSection.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/sections/VisualSettingsSection.kt index 40a992be1..3eaa8d20b 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/sections/VisualSettingsSection.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/sections/VisualSettingsSection.kt @@ -145,7 +145,8 @@ fun VisualSettingsSection( } }, description = if (settings.useNativeTitleBar) { - "Native macOS title bar with proper fullscreen (no transparency)" + "Native macOS title bar and traffic lights over the terminal background, " + + "with proper fullscreen. Transparency is not available with it." } else { "Custom title bar with transparency support" } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt index 8a4183320..ee0bcfdf4 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import ai.rever.bossterm.compose.vcs.GitUtils import ai.rever.bossterm.compose.ComposeQuestioner import ai.rever.bossterm.compose.ComposeTerminalDisplay +import ai.rever.bossterm.compose.notificationTitle import ai.rever.bossterm.compose.ConnectionState import ai.rever.bossterm.compose.PlatformServices import ai.rever.bossterm.compose.putBossTermGraphicsEnvironment @@ -41,6 +42,7 @@ import ai.rever.bossterm.compose.TerminalSession import ai.rever.bossterm.core.typeahead.TerminalTypeAheadManager import ai.rever.bossterm.core.typeahead.TypeAheadTerminalModel import ai.rever.bossterm.terminal.util.GraphemeBoundaryUtils +import java.util.concurrent.atomic.AtomicReference /** * Return the full index permutation for moving a tab only among [movableIndices]. @@ -150,6 +152,20 @@ class TabController( val titleResetListener = object : ai.rever.bossterm.terminal.model.CommandStateListener { override fun onPromptStarted() { session.title.value = session.customTitle.value ?: cwdLabel(session.workingDirectory.value) + + // Clear the OSC 2 window title too, so a program that set one stops naming + // the window after it exits; resolveWindowTitle then falls back to this same + // tab title. See AGENTS.md, "OSC 1 names the TAB, OSC 2 names the WINDOW" for + // why the split is kept and why the reset lives on THIS hook rather than on + // command start. Short version: our integration is sourced from .zshenv so its + // hooks run first, and a shell's own precmd OSC 2 lands just after this and + // survives; clearing at command start instead left the slot empty for the whole + // duration of every command, which fed "BossTerm" to the completion notification. + // + // display, not terminal.setWindowTitle: that publishes to every application-title + // listener as though the program had set an empty title, including + // EmbeddableTerminal's public onTitleChange. + session.display.windowTitle = "" } } session.terminal.addCommandStateListener(titleResetListener) @@ -160,6 +176,12 @@ class TabController( // ProperTerminal also collects this, but only for the active tab's mounted // Composable; this runs for the session's whole life regardless of focus. // customTitle (Rename…) always wins and is re-asserted by the snapshotFlow above. + // Deliberately NOT the OSC 2 window title as well: xterm's split, which this + // codebase follows, is that OSC 1 names the TAB and OSC 2 names the WINDOW, and + // apps set them to different strings. oh-my-zsh is the case that bites - precmd + // emits a short OSC 1 ("~/src") and a long OSC 2 ("me@host: ~/src") back to back, + // so folding both in here would make the tab label whichever arrived last. The + // window title picks up OSC 2 at its own consumer in TabbedTerminal instead. session.coroutineScope.launch { session.display.iconTitleFlow.collect { newTitle -> if (newTitle.isNotEmpty() && session.customTitle.value == null) { @@ -515,10 +537,11 @@ class TabController( // Register command state listener for notifications (OSC 133 shell integration). // Also captured in `tab.commandStateListeners` after construction so dispose() // can remove it (see TerminalTab.commandStateListeners docs). + val notificationTitleProvider = NotificationTitleProvider(display, "BossTerm") val notificationHandler = CommandNotificationHandler( settings = settings, isWindowFocused = isWindowFocused, - tabTitle = { display.windowTitle?.ifEmpty { "BossTerm" } ?: "BossTerm" } + tabTitle = notificationTitleProvider, ) terminal.addCommandStateListener(notificationHandler) @@ -605,6 +628,7 @@ class TabController( // them when the tab closes. val lastCommandTracker = ai.rever.bossterm.compose.mcp.LastCommandTracker(tab) terminal.addCommandStateListener(lastCommandTracker) + notificationTitleProvider.attach(tab) tab.commandStateListeners.add(notificationHandler) tab.commandStateListeners.add(lastCommandTracker) @@ -900,10 +924,11 @@ class TabController( }) // Register command state listener for notifications (OSC 133 shell integration) + val notificationTitleProvider = NotificationTitleProvider(display, sessionTitle) val notificationHandler = CommandNotificationHandler( settings = settings, isWindowFocused = isWindowFocused, - tabTitle = { display.windowTitle?.ifEmpty { sessionTitle } ?: sessionTitle } + tabTitle = notificationTitleProvider, ) terminal.addCommandStateListener(notificationHandler) @@ -989,6 +1014,7 @@ class TabController( // pane closes. val lastCommandTracker = ai.rever.bossterm.compose.mcp.LastCommandTracker(session) terminal.addCommandStateListener(lastCommandTracker) + notificationTitleProvider.attach(session) session.commandStateListeners.add(notificationHandler) session.commandStateListeners.add(lastCommandTracker) @@ -1146,10 +1172,11 @@ class TabController( }) // Register command state listener for notifications (OSC 133 shell integration) + val notificationTitleProvider = NotificationTitleProvider(display, "BossTerm") val notificationHandler = CommandNotificationHandler( settings = settings, isWindowFocused = isWindowFocused, - tabTitle = { display.windowTitle?.ifEmpty { "BossTerm" } ?: "BossTerm" } + tabTitle = notificationTitleProvider, ) terminal.addCommandStateListener(notificationHandler) @@ -1205,6 +1232,7 @@ class TabController( // are recorded on the tab so dispose() can remove them. val lastCommandTracker = ai.rever.bossterm.compose.mcp.LastCommandTracker(tab) terminal.addCommandStateListener(lastCommandTracker) + notificationTitleProvider.attach(tab) tab.commandStateListeners.add(notificationHandler) tab.commandStateListeners.add(lastCommandTracker) @@ -2130,3 +2158,36 @@ class TabController( } } } + +/** + * The title a [CommandNotificationHandler] announces, resolved the same way as the window title so + * the two agree on which session finished. + * + * Exists as a class rather than three lambdas because the tab does not exist yet when the handler + * is constructed, and because that ordering is not the only guarantee needed: the slot is written + * on the constructing thread and read on the terminal reader thread, where `onCommandFinished` + * dispatches from. A captured `var` compiles to a non-volatile field with no happens-before edge + * between the two, so the reader is not guaranteed to see the assignment at all. [AtomicReference] + * gives that edge for nothing. + */ +internal class NotificationTitleProvider( + private val display: ComposeTerminalDisplay, + private val fallback: String, +) : () -> String { + private val tab = AtomicReference(null) + + /** Called once the tab exists; the lambda is not invoked before a command finishes. */ + fun attach(tab: TerminalTab) = this.tab.set(tab) + + override fun invoke(): String { + val tab = tab.get() + return notificationTitle( + custom = tab?.customTitle?.value, + osc2 = display.windowTitle.orEmpty(), + // Through the tab, not display.iconTitle: nothing ever resets that slot, so it would + // still say "vim" during the next long build. + tabTitle = tab?.title?.value.orEmpty(), + fallback = fallback, + ) + } +} diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt new file mode 100644 index 000000000..b3855ddb8 --- /dev/null +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt @@ -0,0 +1,162 @@ +package ai.rever.bossterm.compose.window + +import ai.rever.bossterm.compose.shell.ShellCustomizationUtils +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import java.awt.Window +import javax.swing.JRootPane +import javax.swing.RootPaneContainer + +/** + * Height the native macOS title bar occupies once the content extends underneath it. + * + * A constant rather than a measurement: with `fullWindowContent` the content pane fills the whole + * frame, so the usual `window.height - contentPane.height` trick reports zero. 28pt is the + * standard title bar height for a regular-sized window, and it is what the traffic lights are + * laid out against. + */ +val NATIVE_TITLE_BAR_HEIGHT: Dp = 28.dp + +/** + * Make the native title bar part of the window rather than a strip above it. + * + * `fullWindowContent` lets the content pane extend under the title bar and `transparentTitleBar` + * stops the system painting its own background there, so the app's own background runs edge to + * edge and the traffic lights sit directly on it. That is the look every modern terminal has, and + * it is the one part of "native window styling" reachable without giving something up. + * + * Deliberately NOT transparency. Those are separate things and only this one is available with a + * native title bar: AWT allocates an alpha-capable backing store only for windows it considers + * translucent, and refuses that for decorated frames (`IllegalComponentStateException: The frame + * is decorated`). Forcing the `NSWindow` non-opaque underneath is not enough - measured, the + * window reports `isOpaque = NO` and still composites alpha onto black, because the surface has + * no alpha channel. macOS itself allows it, which is how Terminal.app is see-through with traffic + * lights, but not through AWT's window. Transparency therefore remains the undecorated path's. + * + * Client properties are the supported JDK route on macOS and are simply ignored elsewhere, so + * this is gated on macOS only to keep the intent obvious. + * + * @return true when the style was applied, so callers know whether to inset their content by + * [NATIVE_TITLE_BAR_HEIGHT] to keep it clear of the traffic lights. False off macOS, or + * for a window with no root pane, in which case the caller must not inset - leaving + * today's behaviour rather than a stray gap. + */ +fun applyFullWindowContent(window: Window): Boolean = + // RootPaneContainer is the interface the JDK itself uses for this, and it covers JWindow too. + applyFullWindowContent((window as? RootPaneContainer)?.rootPane) + +/** + * The decision and the three property writes, split out from finding the root pane so a headless + * test can reach them: no [java.awt.Window] can be constructed headless (its constructor throws + * `HeadlessException`) but a bare [JRootPane] can, so this is the largest part of the contract CI + * can actually execute. + * + * @param rootPane null when the window has no root pane to style, which is a decline, not a crash. + * @param isMacOS injectable so a test on any runner can assert both branches, rather than each + * platform's CI silently skipping half the contract. + */ +internal fun applyFullWindowContent( + rootPane: JRootPane?, + isMacOS: Boolean = ShellCustomizationUtils.isMacOS(), +): Boolean { + // Null first: a window with no root pane is a decline on every platform, so checking the + // platform ahead of it would make the null case unreachable off macOS. + if (rootPane == null) return false + if (!isMacOS) return false + + return try { + rootPane.putClientProperty("apple.awt.fullWindowContent", true) + // This is what makes the title's colour our problem: AppKit then draws the title text in + // the colour the window's EFFECTIVE APPEARANCE dictates, not one picked to contrast with + // whatever shows through. See [nativeTitleBarAppearance], which is why the app no longer + // simply follows the system here. Confirmed by hand: with the stock + // -Dapple.awt.application.appearance=system, a light system appearance drew a near-black + // title over the dark terminal background and the window name was unreadable. + rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + // The title stays visible. It is drawn centred in the title bar strip, and callers reserve + // exactly that strip with NATIVE_TITLE_BAR_HEIGHT, so there is nothing for it to overlap + // - hiding it would just lose the window name for no reason. + rootPane.putClientProperty("apple.awt.windowTitleVisible", true) + true + } catch (e: Exception) { + // putClientProperty on a JRootPane essentially cannot throw, so reaching here means + // something is badly wrong - exactly when a silent un-inset window would be the worst + // outcome to debug. + System.err.println("NativeTitleBarStyle: could not apply full window content: $e") + false + } +} + +/** + * How much of the top the native title bar is covering, and therefore how far content must be + * pushed down to stay clear of the traffic lights. Zero whenever nothing needs reserving, so + * callers can apply it unconditionally instead of repeating the predicate. + * + * Repeating it is exactly what went wrong once already: two copies drifted, one of them handled + * fullscreen and the other did not, and the un-inset one rendered inside the title bar strip. + * + * Fullscreen ONLY, never Maximized. macOS zoom keeps its title bar, so a zoomed window still needs + * the inset; native fullscreen hides the bar entirely, so reserving there would leave a dead band + * of background above the content. + * + * [placement] is trustworthy for this, traced rather than assumed: Compose syncs it from a + * `componentResized` handler specifically because "fullscreen changing doesn't fire + * windowStateChanged, only componentResized", and the value it reads bottoms out in skiko's + * `osxIsFullscreenNative` - the real NSWindow state, not a flag set only when we request + * fullscreen. So a fullscreen entered from the green traffic light is covered. A bounds-vs-screen + * heuristic was tried in its place and removed: it cannot tell fullscreen from a zoomed window + * once the menu bar and Dock auto-hide, and guessing wrong there slides the tab bar under the + * traffic lights. + * + * @param styleApplied what [applyFullWindowContent] returned. False means the platform is drawing + * its own title bar above the content, which needs no inset at all. + */ +fun titleBarInset(styleApplied: Boolean, placement: WindowPlacement): Dp = + if (styleApplied && placement != WindowPlacement.Fullscreen) NATIVE_TITLE_BAR_HEIGHT else 0.dp + +/** + * Whether a background colour is dark enough that light text belongs on it. + * + * @param argbHex the stored `defaultBackground`, an `0xAARRGGBB` string. + * @return true for anything unparseable, because the shipped default is dark and a wrong guess + * towards light is the one that produces unreadable text. + */ +internal fun isDarkBackground(argbHex: String): Boolean { + val value = argbHex.removePrefix("0x").removePrefix("0X").toULongOrNull(16) ?: return true + val red = ((value shr 16) and 0xFFu).toDouble() + val green = ((value shr 8) and 0xFFu).toDouble() + val blue = (value and 0xFFu).toDouble() + // Rec. 709 luma. The channels are weighted because the eye is far more sensitive to green + // than to blue, so a plain average calls colours like deep blue "light" when they read black. + return (0.2126 * red + 0.7152 * green + 0.0722 * blue) < 128.0 +} + +/** + * The value for `apple.awt.application.appearance`, or null to leave it alone. + * + * The native title bar makes this necessary. `transparentTitleBar` stops the system painting its + * own strip, so the title text ends up over OUR background, but AppKit still picks that text's + * colour from the window appearance. Following the SYSTEM appearance therefore guarantees an + * unreadable title whenever the two disagree, which was confirmed by hand: light system appearance + * plus the default dark terminal background gave a near-black title on near-black. + * + * Deriving it from the terminal background instead fixes both directions at once, and does it + * through a supported system property rather than JNA. There is no per-window appearance client + * property to reach for - `CPlatformWindow` honours only `fullWindowContent`, + * `transparentTitleBar`, `windowTitleVisible`, `fullscreenable` and some fade/shadow keys - and the + * ObjC bridge in `window/WindowTransparency.kt` is not the alternative it looks like: its only + * consumer is commented out there as having "compatibility issues with modern macOS/Java". + * + * The cost is that this is app-wide, so other AWT chrome (notably the native context menus) follows + * the terminal background rather than the system. For a terminal that is the more consistent + * answer, and it only applies on the native title bar path: with a custom title bar there is no + * system-drawn title to keep legible, so the system appearance is left alone. + * + * Must be applied BEFORE AWT boots - the property is read once at initialisation. + */ +fun nativeTitleBarAppearance(useNativeTitleBar: Boolean, backgroundHex: String): String? { + if (!ShellCustomizationUtils.isMacOS()) return null + if (!useNativeTitleBar) return null + return if (isDarkBackground(backgroundHex)) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua" +} diff --git a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/WindowTitleResolutionTest.kt b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/WindowTitleResolutionTest.kt new file mode 100644 index 000000000..353860447 --- /dev/null +++ b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/WindowTitleResolutionTest.kt @@ -0,0 +1,112 @@ +package ai.rever.bossterm.compose + +import ai.rever.bossterm.compose.tabs.NotificationTitleProvider +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Which title the OS window shows, which is the behaviour that changed for every user: the window + * used to track OSC 2 alone and sat on its startup name for the shells that never emit it. + * + * The three candidates are deliberately NOT interchangeable. A Rename… is the user talking and + * outranks anything a program says; OSC 2 is what an app sets for the WINDOW specifically (xterm's + * split, where OSC 1 names the tab instead); the tab title is the shared fallback that keeps the + * title bar and the tab bar agreeing when nothing more specific has been said. + */ +class WindowTitleResolutionTest { + @Test + fun `a renamed tab outranks whatever the app called the window`() { + assertEquals( + "deploy", + resolveWindowTitle(custom = "deploy", osc2 = "me@host: ~/src", tabTitle = "src"), + ) + } + + @Test + fun `an app's OSC 2 title outranks the tab title`() { + // The case OSC 2 exists for: an app naming the window something longer or more specific + // than the tab label beside it. + assertEquals( + "me@host: ~/src", + resolveWindowTitle(custom = null, osc2 = "me@host: ~/src", tabTitle = "src"), + ) + } + + @Test + fun `an empty OSC 2 title falls back to the tab title`() { + // Empty is the RESET written at each prompt start, not a real title - without the + // fallback an exited program would leave the window nameless. + assertEquals("src", resolveWindowTitle(custom = null, osc2 = "", tabTitle = "src")) + } + + @Test + fun `a rename still wins once the app title has been reset`() { + assertEquals("deploy", resolveWindowTitle(custom = "deploy", osc2 = "", tabTitle = "src")) + } + + @Test + fun `nothing to show stays empty for the caller to suppress`() { + // The collector guards on isNotEmpty, so this must not invent a placeholder - that guard + // is what stops a window being retitled to nothing during startup. + assertEquals("", resolveWindowTitle(custom = null, osc2 = "", tabTitle = "")) + } + + @Test + fun `a whitespace OSC 2 title falls through like a blank rename does`() { + // Same reasoning as the custom title: letting whitespace win leaves the window looking + // nameless, which is worse than the fallback it was covering up. + assertEquals("src", resolveWindowTitle(custom = null, osc2 = " ", tabTitle = "src")) + assertEquals("src", resolveWindowTitle(custom = " ", osc2 = "", tabTitle = "src")) + } + + // ---- the same precedence, as a completion notification sees it ---- + + @Test + fun `a notification names the session, not a program that already exited`() { + // The regression this guards: display.iconTitle is never reset, so falling back to it left + // a finished build announcing "vim" long after vim had quit. The tab title is re-asserted + // at each prompt, so it reverts. + assertEquals( + "src", + notificationTitle(custom = null, osc2 = "", tabTitle = "src", fallback = "BossTerm"), + ) + } + + @Test + fun `a notification prefers what the running program calls itself`() { + assertEquals( + "build.gradle.kts", + notificationTitle( + custom = null, + osc2 = "build.gradle.kts", + tabTitle = "src", + fallback = "BossTerm", + ), + ) + } + + @Test + fun `a notification falls back to the app name only with nothing to say`() { + assertEquals( + "BossTerm", + notificationTitle(custom = null, osc2 = "", tabTitle = "", fallback = "BossTerm"), + ) + } + + @Test + fun `a notification before its tab is attached falls back rather than crashing`() { + // NotificationTitleProvider is built before the TerminalTab exists and has the tab dropped + // in afterwards. Nothing should invoke it in between, but if anything ever does, the + // answer has to be the old behaviour rather than an exception on a background thread. + val display = ComposeTerminalDisplay() + display.windowTitle = "me@host: ~/src" + val provider = NotificationTitleProvider(display, fallback = "BossTerm") + + // The app's own OSC 2 title still comes through with no tab attached... + assertEquals("me@host: ~/src", provider()) + + // ...and with nothing at all, the fallback, which is exactly pre-PR behaviour. + display.windowTitle = "" + assertEquals("BossTerm", provider()) + } +} diff --git a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt new file mode 100644 index 000000000..2ab1b8241 --- /dev/null +++ b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt @@ -0,0 +1,129 @@ +package ai.rever.bossterm.compose.window + +import ai.rever.bossterm.compose.shell.ShellCustomizationUtils +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import javax.swing.JRootPane +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The part of the native title bar style a headless run can see. + * + * Whether the traffic lights end up painted on the app's own background is an AppKit rendering + * property and is not assertable here - that stays manual. What IS assertable is the contract the + * caller depends on: the return value decides whether the window reserves + * [NATIVE_TITLE_BAR_HEIGHT] at the top, so a wrong answer is either a 28dp dead band or a tab bar + * under the traffic lights. + * + * These go through the [JRootPane] overload because no [java.awt.Window] can be constructed in a + * headless JVM at all - the constructor throws `HeadlessException`, and CI is headless on every + * runner including macOS. The platform is passed in rather than detected so that every assertion + * runs on every runner; otherwise each machine would skip the half of the contract it cannot see. + */ +class NativeTitleBarStyleTest { + @Test + fun `a window with no root pane is declined on every platform`() { + // Returning false is what stops the caller reserving 28dp for a title bar that will never + // be styled, which would show up as a strip of background above the tabs. + assertFalse(applyFullWindowContent(rootPane = null, isMacOS = true)) + assertFalse(applyFullWindowContent(rootPane = null, isMacOS = false)) + } + + @Test + fun `on macOS the three client properties that produce the style are set`() { + val rootPane = JRootPane() + assertTrue(applyFullWindowContent(rootPane, isMacOS = true), "macOS accepts a real root pane") + + // fullWindowContent extends the content pane under the title bar, transparentTitleBar stops + // the system painting its own background over it. Either one alone gives a broken look: + // the first without the second leaves a grey strip, the second without the first leaves the + // content below it. + assertEquals(true, rootPane.getClientProperty("apple.awt.fullWindowContent")) + assertEquals(true, rootPane.getClientProperty("apple.awt.transparentTitleBar")) + // The window title stays visible; the caller reserves exactly the strip it is drawn in. + assertEquals(true, rootPane.getClientProperty("apple.awt.windowTitleVisible")) + } + + @Test + fun `off macOS nothing is applied and the caller does not inset`() { + // These client properties are read by the macOS JDK only. Elsewhere the platform draws its + // own decorations, so this must be a no-op and the window must not reserve the strip. + val rootPane = JRootPane() + assertFalse(applyFullWindowContent(rootPane, isMacOS = false)) + assertNull(rootPane.getClientProperty("apple.awt.fullWindowContent")) + } + + // ---- titleBarInset: the subtlest decision here, and the one that already drifted ---- + + @Test + fun `a zoomed window still reserves the strip`() { + // The distinction the whole function exists for. macOS zoom KEEPS its title bar, so a + // maximized window must still inset or the tab bar slides under the traffic lights. + assertEquals(NATIVE_TITLE_BAR_HEIGHT, titleBarInset(true, WindowPlacement.Maximized)) + assertEquals(NATIVE_TITLE_BAR_HEIGHT, titleBarInset(true, WindowPlacement.Floating)) + } + + @Test + fun `fullscreen reserves nothing because the title bar is gone`() { + // Reserving here would leave a dead band of background above the content. + assertEquals(0.dp, titleBarInset(true, WindowPlacement.Fullscreen)) + } + + @Test + fun `without the style applied nothing is reserved in any placement`() { + // Off macOS, or for a window that could not be styled: the platform draws its own title + // bar above the content, so an inset would be pure dead space. + for (placement in WindowPlacement.entries) { + assertEquals(0.dp, titleBarInset(false, placement), "unstyled $placement must not inset") + } + } + + // ---- the appearance the title text is drawn from ---- + + @Test + fun `the shipped default background is dark`() { + // 0xFF05070B. If this ever reads light, every window ships an unreadable title. + assertTrue(isDarkBackground("0xFF05070B")) + } + + @Test + fun `luminance is weighted, not averaged`() { + // A plain channel average calls saturated blue "light" at 0,0,255 (avg 85 vs the 128 + // threshold is dark, but 0,0,255 against a naive max/mid test is not), while green at the + // same value is genuinely light. Weighting is what separates them. + assertTrue(isDarkBackground("0xFF0000FF"), "saturated blue reads as dark") + assertFalse(isDarkBackground("0xFF00FF00"), "saturated green reads as light") + } + + @Test + fun `an unparseable background is treated as dark`() { + // Guessing "light" would put dark text on what is probably a dark background. The shipped + // default is dark, so this is the safe direction. + assertTrue(isDarkBackground("not a colour")) + assertTrue(isDarkBackground("")) + } + + @Test + fun `appearance follows the background, not the system`() { + if (!ShellCustomizationUtils.isMacOS()) return + assertEquals( + "NSAppearanceNameDarkAqua", + nativeTitleBarAppearance(useNativeTitleBar = true, backgroundHex = "0xFF05070B"), + ) + assertEquals( + "NSAppearanceNameAqua", + nativeTitleBarAppearance(useNativeTitleBar = true, backgroundHex = "0xFFFFFFFF"), + ) + } + + @Test + fun `the custom title bar leaves the system appearance alone`() { + // Nothing system-drawn to keep legible there, so forcing the whole app's chrome would be + // an unrelated change the user did not ask for. + assertNull(nativeTitleBarAppearance(useNativeTitleBar = false, backgroundHex = "0xFF05070B")) + } +}