Place apps below the title bar, not behind it
A child webview is positioned against the window frame while the shell measures the hole it left from inside the content view. Borderless, those origins coincided and it never showed. With a title bar back they are a title bar apart, so every app was drawn that much too high - painting over the right-hand part of the bar, which read as the bar being tinted by whichever site was open, and leaving a strip of the same height along the bottom. Tauri cannot report that height. Both inner_position/outer_position and inner_size/outer_size come back identical on macOS - measured, both differences were zero against a window whose content is plainly a title bar shorter than its frame. NSWindow.contentLayoutRect knows, so it is asked once and cached. Also hides the window title, and centres the navigation row in its own strip rather than letting it crowd the bar above it.
This commit is contained in:
@@ -281,6 +281,23 @@ a corner radius applied to each app's layer because a native view sitting on top
|
||||
shell cannot be clipped by the CSS around it. It is gone. A window that behaves like a
|
||||
window is worth more than one that looks bespoke.
|
||||
|
||||
### The title bar offset
|
||||
|
||||
A child webview is positioned against the **window frame**; the shell measures the hole it
|
||||
left from inside the **content view**. With a borderless window those origins coincide, so
|
||||
this never came up. With a title bar they are a title bar apart, and every app was drawn
|
||||
that much too high: it painted over the right-hand part of the bar — which read as the bar
|
||||
being tinted by whichever site was open — and left a strip of the same height along the
|
||||
bottom.
|
||||
|
||||
Tauri cannot report that height. Measured on this machine, `inner_position` and
|
||||
`outer_position` return the same point, and `inner_size` and `outer_size` return the same
|
||||
size, against a window whose content is plainly a title bar shorter than its frame. Both
|
||||
differences are zero and both are useless.
|
||||
|
||||
`NSWindow.contentLayoutRect` knows. The height is asked for once through it and cached,
|
||||
since it does not change.
|
||||
|
||||
The margin That margin is the only part of the window that is not a web page, and
|
||||
therefore the only place left to grab it by. Full screen has no use for it and gets the
|
||||
room back.
|
||||
|
||||
@@ -27,7 +27,7 @@ uuid = { version = "1", features = ["v4"] }
|
||||
objc2 = "0.6"
|
||||
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences", "WKSnapshotConfiguration", "block2"] }
|
||||
objc2-app-kit = { version = "0.3", features = ["NSImage", "NSBitmapImageRep", "NSImageRep", "NSGraphics"] }
|
||||
objc2-foundation = { version = "0.3", features = ["NSData", "NSString", "NSDictionary", "NSValue", "NSError"] }
|
||||
objc2-foundation = { version = "0.3", features = ["NSData", "NSString", "NSDictionary", "NSValue", "NSError", "NSGeometry"] }
|
||||
block2 = "0.6"
|
||||
# Notifications are raised here rather than through the plugin, which offers no
|
||||
# way to learn that one was clicked.
|
||||
|
||||
@@ -17,6 +17,9 @@ pub struct AppState {
|
||||
pub dir: PathBuf,
|
||||
/// Corner radius the window's inner frame is currently drawn with.
|
||||
pub radius: Mutex<f64>,
|
||||
/// Title bar height: how far a child webview's origin sits above the
|
||||
/// content the shell measures from.
|
||||
pub chrome: Mutex<f64>,
|
||||
pub config: Mutex<Config>,
|
||||
pub active: Mutex<Option<String>>,
|
||||
pub stage: Mutex<Stage>,
|
||||
@@ -72,7 +75,8 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
|
||||
let config = config::load(&dir)?;
|
||||
Ok(AppState {
|
||||
dir,
|
||||
radius: Mutex::new(12.0),
|
||||
radius: Mutex::new(0.0),
|
||||
chrome: Mutex::new(0.0),
|
||||
config: Mutex::new(config),
|
||||
active: Mutex::new(None),
|
||||
stage: Mutex::new((240.0, 38.0, 800.0, 600.0)),
|
||||
@@ -524,9 +528,15 @@ pub fn notification_status(app: AppHandle) -> String {
|
||||
let app_state = app.state::<AppState>();
|
||||
let last = app_state.last_notification.lock().unwrap().clone();
|
||||
let from_page = if last.is_empty() { "none yet".into() } else { last };
|
||||
let stage = *app_state.stage.lock().unwrap();
|
||||
let offset = webviews::chrome_offset(&app);
|
||||
let shot = app_state.last_snapshot.lock().unwrap().clone();
|
||||
let shot = if shot.is_empty() { "not taken".into() } else { shot };
|
||||
format!("permission: {state} · direct: {raised} · from page: {from_page} · backdrop: {shot}")
|
||||
format!(
|
||||
"permission: {state} · direct: {raised} · from page: {from_page} · backdrop: {shot} \
|
||||
· stage: {:.0},{:.0} {:.0}×{:.0} · chrome offset: {offset:.1}",
|
||||
stage.0, stage.1, stage.2, stage.3
|
||||
)
|
||||
}
|
||||
|
||||
/// Asks macOS for notification permission, and claims this app's identity.
|
||||
|
||||
@@ -82,6 +82,13 @@ pub fn run() {
|
||||
// the prompt does not arrive attached to someone else's message.
|
||||
commands::ensure_notification_permission(&app.handle().clone());
|
||||
|
||||
// Measured once: every app is positioned against the window frame
|
||||
// while the shell measures from inside the content view, and the
|
||||
// two are a title bar apart.
|
||||
let handle = app.handle().clone();
|
||||
let chrome = webviews::measure_chrome(&handle);
|
||||
*handle.state::<commands::AppState>().chrome.lock().unwrap() = chrome;
|
||||
|
||||
let theme = app.state::<commands::AppState>().cfg().settings.theme;
|
||||
commands::apply_window_theme(&app.handle().clone(), &theme);
|
||||
Ok(())
|
||||
|
||||
@@ -587,7 +587,7 @@ pub fn create(
|
||||
window
|
||||
.add_child(
|
||||
builder,
|
||||
LogicalPosition::new(stage.0, stage.1),
|
||||
LogicalPosition::new(stage.0, stage.1 + chrome_offset(handle)),
|
||||
LogicalSize::new(stage.2, stage.3),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -601,6 +601,62 @@ pub fn create(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How far the window's frame sits above its content, in logical pixels.
|
||||
///
|
||||
/// A child webview is positioned against the window frame, but the shell
|
||||
/// measures the hole it left from inside the content view — and with a title
|
||||
/// bar those two origins are a title bar apart. Without this every app is drawn
|
||||
/// a title bar too high: it paints over the right half of the bar, which is why
|
||||
/// the bar looked like it was tinted by whichever site was open, and leaves an
|
||||
/// empty strip of the same height along the bottom.
|
||||
///
|
||||
/// Asks AppKit how tall the title bar is, and remembers the answer.
|
||||
///
|
||||
/// Tauri cannot say. Both `inner_position`/`outer_position` and
|
||||
/// `inner_size`/`outer_size` come back identical on macOS — measured, both
|
||||
/// reported a difference of zero against a window whose content is plainly a
|
||||
/// title bar shorter than its frame. `contentLayoutRect` is the one thing that
|
||||
/// knows, so it is asked once and cached; the height does not change.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn measure_chrome(handle: &AppHandle) -> f64 {
|
||||
use objc2::runtime::AnyObject;
|
||||
use objc2_foundation::NSRect;
|
||||
|
||||
let Some(wv) = handle.get_webview_window("main") else { return 0.0 };
|
||||
let (tx, rx) = std::sync::mpsc::channel::<f64>();
|
||||
|
||||
let sent = wv.with_webview(move |platform| unsafe {
|
||||
let view = platform.inner() as *mut AnyObject;
|
||||
if view.is_null() {
|
||||
let _ = tx.send(0.0);
|
||||
return;
|
||||
}
|
||||
let window: *mut AnyObject = objc2::msg_send![view, window];
|
||||
if window.is_null() {
|
||||
let _ = tx.send(0.0);
|
||||
return;
|
||||
}
|
||||
let frame: NSRect = objc2::msg_send![window, frame];
|
||||
let content: NSRect = objc2::msg_send![window, contentLayoutRect];
|
||||
let _ = tx.send((frame.size.height - content.size.height).max(0.0));
|
||||
});
|
||||
|
||||
if sent.is_err() {
|
||||
return 0.0;
|
||||
}
|
||||
rx.recv_timeout(std::time::Duration::from_millis(500)).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn measure_chrome(_: &AppHandle) -> f64 {
|
||||
0.0
|
||||
}
|
||||
|
||||
/// How far the window's frame sits above its content, in logical pixels.
|
||||
pub fn chrome_offset(handle: &AppHandle) -> f64 {
|
||||
*handle.state::<crate::commands::AppState>().chrome.lock().unwrap()
|
||||
}
|
||||
|
||||
fn radius(handle: &AppHandle) -> f64 {
|
||||
*handle.state::<crate::commands::AppState>().radius.lock().unwrap()
|
||||
}
|
||||
@@ -623,9 +679,10 @@ pub fn show_only(
|
||||
cfg: &Config,
|
||||
stage: (f64, f64, f64, f64),
|
||||
) {
|
||||
let top = stage.1 + chrome_offset(handle);
|
||||
for app in &cfg.apps {
|
||||
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
|
||||
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
|
||||
let _ = wv.set_position(LogicalPosition::new(stage.0, top));
|
||||
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
|
||||
let _ = wv.set_zoom(app.zoom);
|
||||
let _ = wv.show();
|
||||
@@ -649,9 +706,10 @@ pub fn set_stage(
|
||||
radius: f64,
|
||||
) {
|
||||
let cfg = handle.state::<crate::commands::AppState>().cfg();
|
||||
let top = stage.1 + chrome_offset(handle);
|
||||
for app in &cfg.apps {
|
||||
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
|
||||
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
|
||||
let _ = wv.set_position(LogicalPosition::new(stage.0, top));
|
||||
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
|
||||
set_corner_radius(handle, &app.id, radius);
|
||||
}
|
||||
@@ -669,7 +727,10 @@ pub fn set_stage(
|
||||
pub fn hide_all(handle: &AppHandle, cfg: &Config, stage: (f64, f64, f64, f64)) {
|
||||
for app in &cfg.apps {
|
||||
if let Some(wv) = handle.get_webview(&label_for(&app.id)) {
|
||||
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1 + PARKED_OFFSET));
|
||||
let _ = wv.set_position(LogicalPosition::new(
|
||||
stage.0,
|
||||
stage.1 + chrome_offset(handle) + PARKED_OFFSET,
|
||||
));
|
||||
}
|
||||
// Nothing is on screen behind a dialog, so nothing should think it is.
|
||||
set_page_visibility(handle, &app.id, false);
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"height": 900,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"center": true
|
||||
"center": true,
|
||||
"hiddenTitle": true
|
||||
}
|
||||
],
|
||||
"security": { "csp": null }
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function Nav({
|
||||
{/* Only the expander survives the rail's header. Back and forward are
|
||||
a two-finger swipe and reload is ⌘R, so a toolbar here would be
|
||||
clutter standing in for something nobody asked for. */}
|
||||
<div className="flex w-full flex-col items-center border-b border-slate-200 pb-2 dark:border-slate-800">
|
||||
<div className="flex w-full flex-col items-center border-b border-slate-200 py-2.5 dark:border-slate-800">
|
||||
<button onClick={onToggleCollapse} title="Expand" className={ICON_CHROME}>
|
||||
<Collapse open={false} />
|
||||
</button>
|
||||
@@ -173,7 +173,7 @@ export default function Nav({
|
||||
|
||||
return (
|
||||
<aside className={shell} style={{ width: PANEL }}>
|
||||
<header className="flex items-center gap-0.5 border-b border-slate-200 px-1.5 pb-2 dark:border-slate-800">
|
||||
<header className="flex items-center gap-0.5 border-b border-slate-200 px-1.5 py-2.5 dark:border-slate-800">
|
||||
{controls}
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
|
||||
Reference in New Issue
Block a user