diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 83c7797..7fc21e8 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -28,7 +28,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-24.04, windows-2022, macos-15, macos-15-intel] + os: [ubuntu-24.04, windows-2022, macos-26, macos-26-intel] runs-on: ${{ matrix.os }} timeout-minutes: 120 env: @@ -52,6 +52,11 @@ jobs: libwayland-dev libxkbcommon-dev libxkbcommon-x11-dev \ libx11-xcb-dev libxcb1-dev libxcb-render0-dev \ libxcb-shape0-dev libxcb-xfixes0-dev libxcb-xinput-dev libxcb-xkb-dev libxi-dev + - name: Require the AppKit SDK used by this PR + if: runner.os == 'macOS' + run: | + sdk_version="$(xcrun --sdk macosx --show-sdk-version)" + test "${sdk_version%%.*}" -ge 26 - uses: dtolnay/rust-toolchain@stable with: toolchain: "1.97.1" @@ -63,6 +68,10 @@ jobs: if: ${{ !cancelled() }} run: >- cargo test --locked -p nebula-settings reset::tests:: + - name: Test native titlebar alignment + if: runner.os == 'macOS' + run: >- + cargo test --locked -p nebula --bin pebrel --features gpui-test-support platform::window_chrome::macos::tests:: - name: Test all Python helpers if: ${{ !cancelled() }} run: python -m unittest discover -s scripts/tests -v @@ -74,7 +83,7 @@ jobs: run: cargo test --locked --workspace - name: Test all GPUI product and interaction cases if: ${{ !cancelled() }} - run: cargo test --locked -p nebula --bin pebrel --features gpui-test-support + run: cargo test --locked -p nebula --features gpui-test-support - name: Check source architecture if: ${{ !cancelled() && runner.os == 'Linux' }} run: python scripts/check_architecture.py diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml index 3a80e45..426ddd2 100644 --- a/.github/workflows/preview-packages.yml +++ b/.github/workflows/preview-packages.yml @@ -302,10 +302,10 @@ jobs: fail-fast: false matrix: include: - - runner: macos-15 + - runner: macos-26 architecture: aarch64 native_architecture: arm64 - - runner: macos-15-intel + - runner: macos-26-intel architecture: x86_64 native_architecture: x86_64 runs-on: ${{ matrix.runner }} @@ -323,6 +323,11 @@ jobs: - name: Verify native runner architecture run: test "$(uname -m)" = "${{ matrix.native_architecture }}" + - name: Require the modern AppKit SDK for native window controls + run: | + sdk_version="$(xcrun --sdk macosx --show-sdk-version)" + test "${sdk_version%%.*}" -ge 26 + - name: Test native DMG capacity and mounted contents run: python3 -m unittest scripts.tests.test_macos_dmg -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1807f4a..fddf61c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -253,11 +253,11 @@ jobs: fail-fast: false matrix: include: - - runner: macos-15 + - runner: macos-26 architecture: aarch64 native_architecture: arm64 asset_architecture: arm64 - - runner: macos-15-intel + - runner: macos-26-intel architecture: x86_64 native_architecture: x86_64 asset_architecture: x64 @@ -272,6 +272,10 @@ jobs: python-version: "3.13" - name: Verify native runner architecture run: test "$(uname -m)" = "${{ matrix.native_architecture }}" + - name: Require the modern AppKit SDK for native window controls + run: | + sdk_version="$(xcrun --sdk macosx --show-sdk-version)" + test "${sdk_version%%.*}" -ge 26 - name: Test native DMG capacity and mounted contents run: python3 -m unittest scripts.tests.test_macos_dmg -v - uses: dtolnay/rust-toolchain@stable diff --git a/INSTALL.md b/INSTALL.md index e9dcdf8..d8d415d 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -128,7 +128,10 @@ Keep the extracted directory structure intact: Install [rustup](https://rustup.rs) and the build dependencies for your platform. The repository pins Rust 1.97.1 in `rust-toolchain.toml`. Linux dependency packages are listed in `.github/workflows/release.yml`; macOS requires Xcode command-line -tools. Windows requires Windows 10 1809+ / 11 and a supported Rust linker toolchain. +tools with macOS SDK 26 or newer (`xcrun --sdk macosx --show-sdk-version`) for +the native Liquid Glass window controls. This build requirement does not raise +the macOS 14 minimum runtime version. Windows requires Windows 10 1809+ / 11 +and a supported Rust linker toolchain. ```powershell git clone https://github.com/Kuddev/pebrel diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index 8358e5d..451a293 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -181,7 +181,10 @@ objc2-foundation = { version = "0.3.1", default-features = false, features = [ objc2-app-kit = { version = "0.3.1", default-features = false, features = [ "std", "NSColorSpace", + "NSButton", + "NSControl", "NSResponder", + "NSToolbar", "NSView", "NSWindow", ] } diff --git a/nebula_app/src/gpui_shell/workspace/window_titlebar.rs b/nebula_app/src/gpui_shell/workspace/window_titlebar.rs index 2648b65..aa6c875 100644 --- a/nebula_app/src/gpui_shell/workspace/window_titlebar.rs +++ b/nebula_app/src/gpui_shell/workspace/window_titlebar.rs @@ -87,6 +87,7 @@ impl NebulaWorkspace { cx: &mut Context, ) -> gpui::Div { let top_tabs = self.tabs_position == nebula_settings::TabsPositionName::Top; + let native_layout = crate::platform::window_chrome::layout(window); let bar = TitleBar::new() // Leave 8px above and below the existing 32px controls. .h(px(48.0)) @@ -94,8 +95,11 @@ impl NebulaWorkspace { .when(settings_active, |bar| { bar.border_b_1().border_color(crate::gpui_shell::theme::settings_hairline(cx)) }) + .when(top_tabs && native_layout.is_none(), |bar| { + bar.pl(px(top_tabs::TOP_TAB_LEFT_INSET)) + }) .when(top_tabs, |bar| { - bar.pl(px(top_tabs::TOP_TAB_LEFT_INSET)).child(self.render_top_title_bar( + bar.child(self.render_top_title_bar( files_active, git_active, settings_active, @@ -112,6 +116,14 @@ impl NebulaWorkspace { )) }); + // AppKit owns both the control group and its geometry. Read the live + // frames so system layout, resize and full-screen transitions agree. + let bar = if let Some((height, inset)) = native_layout { + bar.h(px(height)).pl(px(inset)) + } else { + bar + }; + div() .relative() .flex_shrink_0() diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index 3cb2442..29265b0 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -310,7 +310,7 @@ fn workspace_window_options(cx: &mut App, focus: bool, role: WindowRole) -> Wind ) }, ); - WindowOptions { + crate::platform::window_chrome::configure_options(WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), window_min_size: Some(size(px(760.0), px(540.0)).min(&bounds.size)), titlebar: Some(TitleBar::title_bar_options()), @@ -318,7 +318,7 @@ fn workspace_window_options(cx: &mut App, focus: bool, role: WindowRole) -> Wind window_background: crate::gpui_shell::wallpaper::initial_background_appearance(), focus, ..Default::default() - } + }) }, #[cfg(windows)] WindowRole::QuickTerminal => { @@ -382,6 +382,7 @@ fn open_workspace_window( let hwnd_out = hwnd_slot.clone(); let handle = cx.open_window(options, move |window, cx| { window.set_window_title(crate::brand::NAME); + crate::platform::window_chrome::configure(window); *hwnd_out.borrow_mut() = native_hwnd(window).unwrap_or_default(); #[cfg(windows)] crate::gpui_shell::set_native_window_icon(window); diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs index a49c7db..868374f 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -27,6 +27,8 @@ pub mod notifications; pub mod shell; pub mod shell_integration; pub mod startup; +#[cfg(feature = "gpui-shell")] +pub(crate) mod window_chrome; pub use capabilities::CAPABILITIES; diff --git a/nebula_app/src/platform/window_chrome.rs b/nebula_app/src/platform/window_chrome.rs new file mode 100644 index 0000000..47424a0 --- /dev/null +++ b/nebula_app/src/platform/window_chrome.rs @@ -0,0 +1,43 @@ +//! Native window decoration belongs to the platform boundary. Workspace code +//! supplies ordinary GPUI options and uses optional geometry without depending +//! on AppKit types or duplicating platform detection. + +use gpui::{Window, WindowOptions}; + +#[cfg(target_os = "macos")] +mod macos; + +pub(crate) fn configure_options(options: WindowOptions) -> WindowOptions { + #[cfg(target_os = "macos")] + { + let mut options = options; + if let Some(titlebar) = options.titlebar.as_mut() { + // A manual position makes GPUI rewrite AppKit's titlebar container, + // breaking the native toolbar layout. + titlebar.traffic_light_position = None; + } + options.app_owns_titlebar_drag = true; + options + } + #[cfg(not(target_os = "macos"))] + { + options + } +} + +pub(crate) fn configure(_window: &Window) { + #[cfg(target_os = "macos")] + macos::configure(_window); +} + +/// Height and left inset when native controls determine the titlebar geometry. +pub(crate) fn layout(_window: &Window) -> Option<(f32, f32)> { + #[cfg(target_os = "macos")] + { + Some(macos::layout(_window)) + } + #[cfg(not(target_os = "macos"))] + { + None + } +} diff --git a/nebula_app/src/platform/window_chrome/macos.rs b/nebula_app/src/platform/window_chrome/macos.rs new file mode 100644 index 0000000..016c80d --- /dev/null +++ b/nebula_app/src/platform/window_chrome/macos.rs @@ -0,0 +1,80 @@ +//! Keep the standard window controls in AppKit's native toolbar hierarchy. + +use gpui::Window; +use objc2::{MainThreadOnly, rc::Retained}; +use objc2_app_kit::{NSToolbar, NSView, NSWindow, NSWindowButton, NSWindowToolbarStyle}; +use objc2_foundation::{NSRect, ns_string}; +use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + +fn native_window(window: &Window) -> Option> { + let handle = HasWindowHandle::window_handle(window).ok()?; + let RawWindowHandle::AppKit(handle) = handle.as_raw() else { return None }; + // SAFETY: GPUI owns this live NSView and calls the adapter on the UI thread. + let view = unsafe { handle.ns_view.cast::().as_ref() }; + view.window() +} + +pub(super) fn configure(window: &Window) { + let Some(native) = native_window(window) else { return }; + let toolbar = NSToolbar::initWithIdentifier( + NSToolbar::alloc(native.mtm()), + ns_string!("pebrel-window-controls"), + ); + // An empty native toolbar supplies the system's current window-control + // treatment. AppKit retains it; GPUI continues to render the app controls. + native.setToolbarStyle(NSWindowToolbarStyle::Unified); + native.setToolbar(Some(&toolbar)); +} + +pub(super) fn layout(window: &Window) -> (f32, f32) { + if window.is_fullscreen() { + return (52.0, 8.0); + } + let Some(native) = native_window(window) else { return (52.0, 96.0) }; + let Some(close) = native.standardWindowButton(NSWindowButton::CloseButton) else { + return (52.0, 96.0); + }; + let Some(zoom) = native.standardWindowButton(NSWindowButton::ZoomButton) else { + return (52.0, 96.0); + }; + let Some(content) = native.contentView() else { return (52.0, 96.0) }; + let close = close.convertRect_toView(close.bounds(), Some(&content)); + let zoom = zoom.convertRect_toView(zoom.bounds(), Some(&content)); + control_layout(close, zoom, content.bounds(), content.isFlipped()) +} + +fn control_layout(close: NSRect, zoom: NSRect, content: NSRect, flipped: bool) -> (f32, f32) { + let center = close.origin.y + close.size.height / 2.0; + let center_from_top = if flipped { + center - content.origin.y + } else { + content.origin.y + content.size.height - center + }; + ( + 2.0 * center_from_top as f32, + (zoom.origin.x + zoom.size.width - content.origin.x + 16.0) as f32, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use objc2_foundation::{NSPoint, NSSize}; + + #[test] + fn app_controls_follow_native_centers_and_leave_room_for_the_group() { + for height in [600.0, 900.0] { + for (inset, center) in [(19.0, 26.0), (12.0, 20.0)] { + for flipped in [false, true] { + let content = NSRect::new(NSPoint::new(5.0, 3.0), NSSize::new(900.0, height)); + let y = 3.0 + if flipped { center - 7.0 } else { height - center - 7.0 }; + let close = NSRect::new(NSPoint::new(5.0 + inset, y), NSSize::new(14.0, 14.0)); + let zoom = NSRect::new(NSPoint::new(5.0 + inset + 46.0, y), close.size); + let (bar_height, left_padding) = control_layout(close, zoom, content, flipped); + assert_eq!(bar_height / 2.0, center as f32); + assert_eq!(left_padding, (inset + 46.0 + 14.0 + 16.0) as f32); + } + } + } + } +} diff --git a/scripts/conformance/tests/test_posix_lifecycle.py b/scripts/conformance/tests/test_posix_lifecycle.py index 8cd3cb8..817ed50 100644 --- a/scripts/conformance/tests/test_posix_lifecycle.py +++ b/scripts/conformance/tests/test_posix_lifecycle.py @@ -29,7 +29,11 @@ role = sys.argv[2] if role != 'grandchild': next_role = 'child' if role == 'parent' else 'grandchild' subprocess.Popen([sys.executable, '-I', '-S', __file__, str(root), next_role]) -(root / (role + '.pid')).write_text(str(os.getpid())) +# Publish the PID only after the contents are complete. The parent polls for +# file existence, so creating the final path first exposes an empty file. +pid_path = root / (role + '.pid.tmp') +pid_path.write_text(str(os.getpid())) +pid_path.replace(root / (role + '.pid')) while True: if role == 'parent' and (root / 'exit').exists(): code = root / 'exit-code'