Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,26 @@ import CmuxNextFocus

// App-wide inputs to the per-window focus coordinators.
extension AppServices {
/// The palette is a panel above the active window: an overlay of that
/// window's focus while it is open. Reported synchronously by the
/// The palette is a panel above the window it opens over: an overlay of
/// that window's focus while it is open. Reported synchronously by the
/// palette, before the key-window change it causes, so a click that
/// closes it is judged with the overlay already gone (input-spec.md B7).
///
/// The overlay goes on the window the panel is a child of, not on
/// `windows.active`: the two disagree whenever the palette opens over a
/// window that is not the active one (an explicit `relativeTo:`, or a
/// press in one window while another holds the keys, where `active` is
/// the event's window and the palette parents to the key window). The
/// overlay then landed on a window the palette was not over, leaving the
/// window under the panel with an empty stack: it goes on applying focus
/// to its terminal while the palette holds the keyboard (input-spec.md
/// W5, W6, and F1's "an overlay is the keyboard target").
func observePaletteForFocus() {
palette.onVisibilityChange = { [weak self] open in
palette.onVisibilityChange = { [weak self] open, host in
guard let self else { return }
if open {
windows.active?.focus.send(.overlayOpened(.palette))
let over = host.flatMap { windows.owner(of: $0) } ?? windows.active
over?.focus.send(.overlayOpened(.palette))
} else {
for controller in windows.controllers where controller.focus.state.overlays.contains(.palette) {
controller.focus.send(.overlayClosed(.palette))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ public final class PaletteController {
/// Called synchronously when the panel opens (true) or closes (false),
/// before any key-window change it causes, so the owner's focus overlay
/// stack never lags the panel (plans/cmux-next/input-spec.md bug B7).
public var onVisibilityChange: ((Bool) -> Void)?
/// The window is the one the panel is a child of, so the owner puts the
/// overlay on the window the palette is actually over (W5, W6) instead
/// of guessing it from the active window, which the palette's own
/// `relativeTo:` and the key window can both disagree with.
public var onVisibilityChange: ((Bool, NSWindow?) -> Void)?
/// Main-thread phases of each open, request to committed first frame
/// (the stall bench, `debug.timings`).
public var onPresented: ((PaletteOpenTiming) -> Void)?
Expand Down Expand Up @@ -272,8 +276,8 @@ public final class PaletteController {
return
}
isVisible = true
onVisibilityChange?(true)
parentWindow = parent
onVisibilityChange?(true, parent)
// The room (theme) of the window it opens over.
(parent?.themeScope ?? .app).adopt(panel)
let size = PaletteLayout.windowSize
Expand Down Expand Up @@ -331,7 +335,7 @@ public final class PaletteController {
guard isVisible, let panel else { return }
isVisible = false
registry.context.remove(.paletteOpen)
onVisibilityChange?(false)
onVisibilityChange?(false, parentWindow)
model.closeActionsMenu()
shortcutRecorder.abandon()
model.shortcutRecorder = nil
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import AppKit
@testable import CmuxNextApp
import CmuxNextFocus
import Testing

/// The palette's focus overlay goes on the window the panel opens over
/// (#4303: "the palette UI is invoked, but the next character typed lands in
/// the terminal"). The overlay used to go on `windows.active`, which is the
/// window an input event came from, else the key window's, else the last
/// active one: none of those is the window the palette parents to when the
/// two disagree. The overlay then sat on a window the palette was not over,
/// and the window under the panel kept an empty overlay stack, so it went on
/// treating its terminal as the keyboard target (input-spec.md W5, W6, F1).
@MainActor @Suite(.serialized)
struct PaletteOverlayWindowTests {
@Test func theOverlayGoesOnTheWindowThePaletteOpensOver() throws {
_ = NSApplication.shared
let services = AppServices(environment: AppEnvironment.current([:]))
services.windows.ordersWindowsIn = false
let over = try #require(services.windows.openWindow(workspaces: ["palette-overlay-over"]))
let elsewhere = try #require(services.windows.openWindow(workspaces: ["palette-overlay-elsewhere"]))
defer {
services.palette.hide()
for controller in services.windows.controllers { controller.window?.close() }
}
let overWindow = try #require(over.window)
// The active window is the other one, as it is for a press in one
// window while another holds the keys.
services.keyWindowSource = { elsewhere.window }
try #require(services.windows.active === elsewhere, "the active window is not the other one")

services.palette.show(.commands, relativeTo: overWindow)

#expect(over.focus.state.overlays.contains(.palette),
"the window the palette opened over has no palette overlay")
#expect(!elsewhere.focus.state.overlays.contains(.palette),
"the palette overlay landed on a window the palette is not over")

services.palette.hide()
#expect(!over.focus.state.overlays.contains(.palette), "the overlay outlived the palette")
}
}
Loading