host: raise_shell on Linux and macOS; tab stills before a hide (Windows)

Linux: the shell becomes a GtkOverlay layer itself (an empty box is the base) and is
re-ordered to the top or bottom of the overlay; a tab placed while it is raised goes under
it. macOS: the shell's WKWebView is re-ordered among its superview's subviews
(addSubview:positioned:relativeTo:), keeping the first responder if it was in the shell.
Both now report support, so overlays no longer hide the tabs there either (the app makes the
shell webview transparent; macOS needs tauri's macos-private-api).

Windows: a tab webview about to be hidden hands the shell its last frame (WebView2
CapturePreview, JPEG, `tab-still` event), for its pane to show instead of an outline while
no webview is on screen there.
This commit is contained in:
CRTkafa 2026-09-29 02:39:48 +07:00
parent 5954aa94ab
commit b5bef3463b
3 changed files with 140 additions and 8 deletions

View file

@ -285,9 +285,11 @@ pub fn attach_shell<R: Runtime>(shell: &Webview<R>) {
/// only: there `Webview::position` is not the placement (always 0,0 outside X11 child windows).
/// Puts the shell webview above every tab webview (`on_top`), so shell menus, dialogs and drag
/// zones draw over the panes while the panes stay on screen (the shell page must then be
/// transparent where the panes are), or back below them. Returns whether this platform can:
/// Windows only (WebView2 hosts each webview in its own child window, re-ordered with
/// `SetWindowPos`; no activation or focus change). Elsewhere the caller hides the tabs instead.
/// transparent where the panes are), or back below them. Returns whether this platform can.
/// Windows: WebView2 hosts each webview in its own child window, re-ordered with `SetWindowPos`
/// (no activation or focus change). Linux: the shell's place among the GtkOverlay's layers
/// ([`attach_shell`]). macOS: the shell's WKWebView re-ordered among its superview's subviews
/// (first responder kept). Elsewhere the caller hides the tabs instead.
pub fn raise_shell<R: Runtime>(shell: &Webview<R>, on_top: bool) -> bool {
#[cfg(windows)]
{
@ -316,13 +318,52 @@ pub fn raise_shell<R: Runtime>(shell: &Webview<R>, on_top: bool) -> bool {
});
true
}
#[cfg(not(windows))]
#[cfg(target_os = "linux")]
{
linux::raise_shell(shell, on_top);
true
}
#[cfg(target_os = "macos")]
{
macos::raise_shell(shell, on_top);
true
}
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
{
let _ = (shell, on_top);
false
}
}
#[cfg(target_os = "macos")]
mod macos {
use objc2_app_kit::{NSResponder, NSView, NSWindowOrderingMode};
use tauri::{Runtime, Webview};
pub fn raise_shell<R: Runtime>(shell: &Webview<R>, on_top: bool) {
let _ = shell.with_webview(move |platform| {
// SAFETY: `inner` is the shell's live WKWebView (an NSView) and its superview (the
// window's content view, which owns it), on the main thread (with_webview runs the
// closure there).
let view: &NSView = unsafe { &*platform.inner().cast::<NSView>() };
let Some(parent) = (unsafe { view.superview() }) else { return };
// Re-adding a subview can resign it as first responder: typing stays where it was.
let window = view.window();
let focused = window.as_ref().and_then(|window| window.firstResponder()).is_some_and(|responder| {
responder.downcast_ref::<NSView>().is_some_and(|responder| responder.isDescendantOf(view))
});
let mode = if on_top { NSWindowOrderingMode::Above } else { NSWindowOrderingMode::Below };
parent.addSubview_positioned_relativeTo(view, mode, None);
if focused {
if let Some(window) = window {
let responder: &NSResponder = view;
window.makeFirstResponder(Some(responder));
}
}
});
}
}
#[cfg(target_os = "linux")]
pub fn placed_rect<R: Runtime>(webview: &Webview<R>) -> Option<(f64, f64, f64, f64)> {
linux::placed(webview.label())
@ -332,6 +373,7 @@ pub fn placed_rect<R: Runtime>(webview: &Webview<R>) -> Option<(f64, f64, f64, f
mod linux {
use gtk::prelude::*;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use tauri::{Runtime, Webview};
@ -349,6 +391,20 @@ mod linux {
rects().get(label).copied()
}
/// The shell is above the tab webviews ([`raise_shell`]).
static RAISED: AtomicBool = AtomicBool::new(false);
/// The shell is the overlay's first layer (under the tabs, which `place` adds above it) or,
/// raised, its last. Tabs placed while it is raised go under it.
pub fn raise_shell<R: Runtime>(shell: &Webview<R>, on_top: bool) {
RAISED.store(on_top, Ordering::SeqCst);
let _ = shell.with_webview(move |platform| {
let widget = platform.inner().upcast::<gtk::Widget>();
let Some(overlay) = widget.parent().and_then(|parent| parent.downcast::<gtk::Overlay>().ok()) else { return };
overlay.reorder_overlay(&widget, if on_top { -1 } else { 0 });
});
}
pub fn attach_shell<R: Runtime>(shell: &Webview<R>) {
let _ = shell.with_webview(|platform| {
let widget = platform.inner().upcast::<gtk::Widget>();
@ -361,9 +417,14 @@ mod linux {
let (x, y, w, h) = unsafe { *child.data::<GtkRect>(RECT_KEY)?.as_ptr() };
Some(gtk::gdk::Rectangle::new(x, y, w, h))
});
// `widget` holds a reference, so removing it from the box does not destroy it.
// `widget` holds a reference, so removing it from the box does not destroy it. The
// shell is an overlay layer too (an empty box is the base), so it can be re-ordered
// above the tabs; with no placement it fills the overlay (Fill alignment).
vbox.remove(&widget);
overlay.add(&widget);
widget.set_halign(gtk::Align::Fill);
widget.set_valign(gtk::Align::Fill);
overlay.add(&gtk::Box::new(gtk::Orientation::Vertical, 0));
overlay.add_overlay(&widget);
vbox.pack_start(&overlay, true, true, 0);
overlay.show();
});
@ -387,17 +448,83 @@ mod linux {
};
vbox.remove(&widget);
overlay.add_overlay(&widget);
if RAISED.load(Ordering::SeqCst) {
overlay.reorder_overlay(&widget, 0);
}
}
widget.queue_resize();
});
}
}
/// The event carrying a tab's last frame to the shell ([`capture_still`]).
pub const EVENT_TAB_STILL: &str = "tab-still";
#[cfg(windows)]
#[derive(Clone, Serialize)]
struct TabStill {
id: String,
/// `data:image/jpeg;base64,…`
image: String,
}
/// Windows: a tab webview about to be hidden hands the shell its last frame (WebView2
/// `CapturePreview`, JPEG) as [`EVENT_TAB_STILL`], which its pane shows while no webview is on
/// screen there. Only a webview that is on screen now; requested before the hide, on the same
/// thread, so the frame is still there. Kept in the shell's memory only. No-op elsewhere.
fn capture_still<R: Runtime>(webview: &Webview<R>) {
#[cfg(windows)]
{
let Some(id) = policy::tab_id(webview.label()).map(str::to_owned) else { return };
let app = webview.app_handle().clone();
let _ = webview.with_webview(move |platform| {
use base64::Engine;
use webview2_com::CapturePreviewCompletedHandler;
use webview2_com::Microsoft::Web::WebView2::Win32::COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_JPEG;
use windows::Win32::System::Com::{STREAM_SEEK_END, STREAM_SEEK_SET};
use windows::Win32::UI::Shell::SHCreateMemStream;
// SAFETY: COM calls on this webview's live controller and a memory stream it writes
// into, on the main thread (with_webview and the completion handler run there).
unsafe {
let controller = platform.controller();
let mut shown = windows::core::BOOL::default();
if controller.IsVisible(&mut shown).is_err() || !shown.as_bool() {
return;
}
let (Ok(core), Some(stream)) = (controller.CoreWebView2(), SHCreateMemStream(None)) else { return };
let target = stream.clone();
let handler = CapturePreviewCompletedHandler::create(Box::new(move |result| {
result?;
let mut size = 0u64;
target.Seek(0, STREAM_SEEK_END, Some(&mut size))?;
if size == 0 || size > 16 << 20 {
return Ok(());
}
target.Seek(0, STREAM_SEEK_SET, None)?;
let mut bytes = vec![0u8; size as usize];
let mut read = 0u32;
target.Read(bytes.as_mut_ptr().cast(), size as u32, Some(&mut read)).ok()?;
bytes.truncate(read as usize);
let image = format!("data:image/jpeg;base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes));
emit_shell(&app, EVENT_TAB_STILL, TabStill { id, image });
Ok(())
}));
let _ = core.CapturePreview(COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_JPEG, &stream, &handler);
}
});
}
#[cfg(not(windows))]
let _ = webview;
}
/// Shows or hides a tab webview and sets its WebView2 memory target to match
/// ([`tabs::memory_target`]): hidden tabs run at `COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_LOW`
/// (ICoreWebView2_19, WebView2 Runtime 114+; older runtimes just skip it). Other platforms only
/// show/hide.
pub fn set_tab_visible<R: Runtime>(webview: &Webview<R>, visible: bool, parked: bool) {
if !visible {
capture_still(webview);
}
let _ = if visible { webview.show() } else { webview.hide() };
#[cfg(windows)]
{