Skip to content

Commit 9d1bea1

Browse files
committed
Merge branch 'feature/up' into develop
2 parents df5366d + 69c3b3f commit 9d1bea1

8 files changed

Lines changed: 302 additions & 102 deletions

File tree

e2e/titlebar.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { expect, test } from './support/test'
2+
3+
test.describe('title bar', () => {
4+
test('shows the app brand on Windows/Linux and aligns its logo to the sidebar', async ({
5+
page,
6+
}) => {
7+
const platformClass = await page.locator('body').evaluate((body) =>
8+
[...body.classList].find((className) => className.startsWith('platform-')),
9+
)
10+
const brand = page.getByTestId('titlebar-brand')
11+
12+
if (platformClass === 'platform-darwin') {
13+
await expect(brand).toHaveCount(0)
14+
return
15+
}
16+
17+
await expect(brand).toBeVisible()
18+
await expect(brand).toContainText('SwitchHosts')
19+
20+
const logo = page.getByTestId('titlebar-logo')
21+
const hostsButton = page.getByLabel('Hosts')
22+
await expect(logo).toBeVisible()
23+
await expect(hostsButton).toBeVisible()
24+
25+
const [logoBox, hostsButtonBox] = await Promise.all([
26+
logo.boundingBox(),
27+
hostsButton.boundingBox(),
28+
])
29+
expect(logoBox).not.toBeNull()
30+
expect(hostsButtonBox).not.toBeNull()
31+
32+
const logoCenter = logoBox!.x + logoBox!.width / 2
33+
const hostsButtonCenter = hostsButtonBox!.x + hostsButtonBox!.width / 2
34+
expect(Math.abs(logoCenter - hostsButtonCenter)).toBeLessThanOrEqual(1)
35+
})
36+
})

src-tauri/src/tray.rs

Lines changed: 102 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}
2626
use tauri::webview::WebviewWindowBuilder;
2727
use tauri::{
2828
AppHandle, Manager, Monitor, PhysicalPosition, Rect as TauriRect, Runtime, WebviewUrl,
29-
WindowEvent,
3029
};
3130

3231
use crate::i18n::menu_labels;
@@ -41,20 +40,35 @@ const TRAY_WINDOW_HEIGHT: f64 = 600.0;
4140

4241
/// Click-toggle dedupe window, in milliseconds.
4342
///
44-
/// macOS dispatches the tray window's focus-loss event before the
45-
/// status item's click handler when the user clicks the tray icon
46-
/// while the mini window is open. Without this dedupe, the focus-loss
47-
/// handler hides the window and the click handler immediately shows
48-
/// it again — turning a "click to dismiss" into a no-op flicker.
49-
/// Recording the auto-hide timestamp and skipping `show` when the click
50-
/// arrives within this window gives us toggle semantics.
43+
/// Some platforms can deliver a tray-window auto-hide before the tray
44+
/// icon's click handler when the user clicks the icon while the mini
45+
/// window is open. Without this dedupe, the auto-hide path hides the
46+
/// window and the click handler immediately shows it again — turning a
47+
/// "click to dismiss" into a no-op flicker. Recording the auto-hide
48+
/// timestamp and skipping `show` when the click arrives within this
49+
/// window gives us toggle semantics.
5150
const TRAY_TOGGLE_DEDUPE_MS: u64 = 300;
5251

53-
/// Wall-clock millis of the last focus-loss-driven auto-hide of the
54-
/// tray window. 0 means "never". `AtomicU64` keeps it lock-free and
55-
/// safe to read/write from any thread.
52+
/// macOS can deliver the status-item mouse-down to our global
53+
/// click-outside monitor after the tray click handler has already
54+
/// scheduled or shown the mini window. Suppress global auto-hide for a
55+
/// very short window after an icon-triggered show so the opening click
56+
/// can't immediately dismiss the window it just requested, while still
57+
/// allowing an intentional outside click right after opening to close it.
58+
#[cfg(target_os = "macos")]
59+
const TRAY_GLOBAL_HIDE_SUPPRESS_AFTER_ICON_CLICK_MS: u64 = 150;
60+
61+
/// Wall-clock millis of the last auto-hide of the tray window. 0 means
62+
/// "never". `AtomicU64` keeps it lock-free and safe to read/write from
63+
/// any thread.
5664
static LAST_TRAY_AUTO_HIDE_MS: AtomicU64 = AtomicU64::new(0);
5765

66+
/// Wall-clock millis of the last tray-icon click that requested a
67+
/// mini-window show. Used only to dedupe macOS global-monitor events
68+
/// from the same physical click.
69+
#[cfg(target_os = "macos")]
70+
static LAST_TRAY_ICON_SHOW_CLICK_MS: AtomicU64 = AtomicU64::new(0);
71+
5872
fn now_ms() -> u64 {
5973
SystemTime::now()
6074
.duration_since(UNIX_EPOCH)
@@ -114,7 +128,7 @@ pub fn install_tray<R: Runtime>(app: &AppHandle<R>) -> Result<(), tauri::Error>
114128
Ok(())
115129
}
116130

117-
fn handle_left_click<R: Runtime>(
131+
fn handle_left_click<R: Runtime + 'static>(
118132
app: &AppHandle<R>,
119133
cursor: PhysicalPosition<f64>,
120134
icon_rect: TauriRect,
@@ -129,16 +143,15 @@ fn handle_left_click<R: Runtime>(
129143
};
130144
if mini_enabled {
131145
// Toggle semantics: a click on the icon while the mini window
132-
// is open should dismiss it. macOS status-item clicks do not
133-
// always steal key-window status from the tray window, so we
134-
// can't rely on the focus-loss path alone — handle both:
146+
// is open should dismiss it. Tray icon clicks and auto-hide
147+
// paths can interleave differently across platforms, so handle
148+
// both:
135149
//
136-
// (a) status-item click did NOT blur the tray window —
137-
// the window is still visible here; hide it explicitly.
138-
// (b) status-item click DID blur the tray window — the
139-
// focus-loss handler already hid it and stamped
140-
// LAST_TRAY_AUTO_HIDE_MS; skip the show so the dismissal
141-
// sticks instead of immediately re-showing.
150+
// (a) the tray window is still visible here; hide it
151+
// explicitly.
152+
// (b) an auto-hide path already hid it and stamped
153+
// LAST_TRAY_AUTO_HIDE_MS; skip re-showing so the
154+
// dismissal sticks.
142155
if let Some(window) = app.get_webview_window(TRAY_WINDOW_LABEL) {
143156
if window.is_visible().unwrap_or(false) {
144157
let _ = window.hide();
@@ -149,11 +162,34 @@ fn handle_left_click<R: Runtime>(
149162
if last_hide != 0 && now_ms().saturating_sub(last_hide) < TRAY_TOGGLE_DEDUPE_MS {
150163
return;
151164
}
165+
show_tray_window_from_tray_click(app, cursor, icon_rect);
166+
} else {
167+
show_main_window(app);
168+
}
169+
}
170+
171+
fn show_tray_window_from_tray_click<R: Runtime + 'static>(
172+
app: &AppHandle<R>,
173+
cursor: PhysicalPosition<f64>,
174+
icon_rect: TauriRect,
175+
) {
176+
#[cfg(target_os = "macos")]
177+
{
178+
LAST_TRAY_ICON_SHOW_CLICK_MS.store(now_ms(), Ordering::Relaxed);
179+
let app_for_show = app.clone();
180+
if let Err(e) = app.run_on_main_thread(move || {
181+
if let Err(e) = show_tray_window(&app_for_show, cursor, icon_rect) {
182+
log::warn!("failed to show mini window: {e}");
183+
}
184+
}) {
185+
log::warn!("failed to schedule mini window show: {e}");
186+
}
187+
}
188+
#[cfg(not(target_os = "macos"))]
189+
{
152190
if let Err(e) = show_tray_window(app, cursor, icon_rect) {
153191
log::warn!("failed to show mini window: {e}");
154192
}
155-
} else {
156-
show_main_window(app);
157193
}
158194
}
159195

@@ -349,22 +385,23 @@ fn show_tray_window<R: Runtime>(
349385
.map_err(|e| e.to_string())?;
350386
}
351387

352-
// On macOS, both `window.show()` and `window.set_focus()` call
353-
// `makeKeyAndOrderFront:`, which activates the app via
354-
// `activateIgnoringOtherApps:`. When the main window is visible
355-
// but unfocused (e.g. another app was active), that activation
356-
// surfaces the main window into key — visible as a brief focus
357-
// flicker on the main window. Bypass Tauri here and call
358-
// `orderFrontRegardless` directly: it puts the tray on top across
359-
// app boundaries without activating our app, so main keeps its
360-
// state. The first click inside the tray webview will activate
361-
// our app naturally and make the tray the key window.
388+
// On macOS, avoid Tauri's `set_focus()` because it unconditionally
389+
// activates the whole app and can surface the main window. If the
390+
// app is inactive, though, the tray window must activate the app or
391+
// it remains a painted-but-not-interactive window that disappears
392+
// as soon as the pointer leaves the status item. Activate only in
393+
// that inactive case, then make the tray window key.
362394
#[cfg(target_os = "macos")]
363395
{
364-
use objc2::{msg_send, runtime::AnyObject};
396+
use objc2::{class, msg_send, runtime::AnyObject};
365397
let ns_window = window.ns_window().map_err(|e| e.to_string())? as *mut AnyObject;
366398
unsafe {
367-
let _: () = msg_send![ns_window, orderFrontRegardless];
399+
let ns_app: *mut AnyObject = msg_send![class!(NSApplication), sharedApplication];
400+
let app_is_active: bool = msg_send![ns_app, isActive];
401+
if !app_is_active {
402+
let _: () = msg_send![ns_app, activateIgnoringOtherApps: true];
403+
}
404+
let _: () = msg_send![ns_window, makeKeyAndOrderFront: std::ptr::null::<AnyObject>()];
368405
}
369406
}
370407
#[cfg(not(target_os = "macos"))]
@@ -399,22 +436,25 @@ fn create_tray_window<R: Runtime>(
399436
.shadow(true)
400437
.build()?;
401438

402-
let window_for_handler = window.clone();
403-
window.on_window_event(move |event| {
404-
// Hide on focus loss so the popover behaves like a real
405-
// menubar mini-window: click outside → it disappears.
406-
// Only stamp the auto-hide timestamp + call hide when the
407-
// window is still visible — otherwise this is the trailing
408-
// blur from a hide we already performed in handle_left_click,
409-
// and re-stamping would freeze the next click out of show via
410-
// the dedupe window.
411-
if let WindowEvent::Focused(false) = event {
412-
if window_for_handler.is_visible().unwrap_or(false) {
413-
LAST_TRAY_AUTO_HIDE_MS.store(now_ms(), Ordering::Relaxed);
414-
let _ = window_for_handler.hide();
439+
#[cfg(not(target_os = "macos"))]
440+
{
441+
let window_for_handler = window.clone();
442+
window.on_window_event(move |event| {
443+
// Hide on focus loss so the popover behaves like a real
444+
// tray mini-window: click outside -> it disappears.
445+
// Only stamp the auto-hide timestamp + call hide when the
446+
// window is still visible -- otherwise this is the trailing
447+
// blur from a hide we already performed in handle_left_click,
448+
// and re-stamping would freeze the next click out of show via
449+
// the dedupe window.
450+
if let tauri::WindowEvent::Focused(false) = event {
451+
if window_for_handler.is_visible().unwrap_or(false) {
452+
LAST_TRAY_AUTO_HIDE_MS.store(now_ms(), Ordering::Relaxed);
453+
let _ = window_for_handler.hide();
454+
}
415455
}
416-
}
417-
});
456+
});
457+
}
418458

419459
Ok(window)
420460
}
@@ -584,7 +624,7 @@ fn install_dismiss_monitors<R: Runtime>(app: &AppHandle<R>) {
584624
// the desktop, the menu bar (incl. the tray icon itself).
585625
let app_for_global = app.clone();
586626
let global_block = RcBlock::new(move |_event: *mut AnyObject| {
587-
hide_tray_if_visible(&app_for_global);
627+
hide_tray_if_visible_unless_recent_icon_show(&app_for_global);
588628
});
589629

590630
// Local monitor: clicks inside our app — fires for the tray window
@@ -630,6 +670,17 @@ fn install_dismiss_monitors<R: Runtime>(app: &AppHandle<R>) {
630670
std::mem::forget(local_block);
631671
}
632672

673+
#[cfg(target_os = "macos")]
674+
fn hide_tray_if_visible_unless_recent_icon_show<R: Runtime>(app: &AppHandle<R>) {
675+
let last_icon_show = LAST_TRAY_ICON_SHOW_CLICK_MS.load(Ordering::Relaxed);
676+
if last_icon_show != 0
677+
&& now_ms().saturating_sub(last_icon_show) < TRAY_GLOBAL_HIDE_SUPPRESS_AFTER_ICON_CLICK_MS
678+
{
679+
return;
680+
}
681+
hide_tray_if_visible(app);
682+
}
683+
633684
#[cfg(target_os = "macos")]
634685
fn hide_tray_if_visible<R: Runtime>(app: &AppHandle<R>) {
635686
if let Some(tray) = app.get_webview_window(TRAY_WINDOW_LABEL) {

src/renderer/components/TopBar/index.module.scss

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
@use '../../styles/common';
22

33
.root {
4-
$w: 230px;
4+
$w: 240px;
55
$p: 10px;
66

77
background: transparent;
@@ -14,6 +14,7 @@
1414
justify-content: space-between;
1515
user-select: none;
1616
position: relative;
17+
box-sizing: border-box;
1718

1819
.title {
1920
max-width: calc(100vw - ($w + $p) * 2);
@@ -24,6 +25,53 @@
2425
}
2526
}
2627

28+
.with_app_brand {
29+
padding-left: 0;
30+
}
31+
32+
.left_cluster {
33+
display: flex;
34+
align-items: center;
35+
gap: 8px;
36+
min-width: 0;
37+
flex: 0 0 auto;
38+
}
39+
40+
.app_brand {
41+
display: flex;
42+
align-items: center;
43+
min-width: 0;
44+
// max-width: 148px;
45+
padding-right: 8px;
46+
flex: 0 1 auto;
47+
pointer-events: none;
48+
color: var(--swh-font-color);
49+
}
50+
51+
.app_logo_slot {
52+
width: var(--swh-left-sidebar-width, 40px);
53+
height: var(--swh-top-bar-height);
54+
display: flex;
55+
align-items: center;
56+
justify-content: center;
57+
flex: 0 0 var(--swh-left-sidebar-width, 40px);
58+
}
59+
60+
.app_logo {
61+
width: 20px;
62+
height: 20px;
63+
display: block;
64+
border-radius: 4px;
65+
}
66+
67+
.app_title {
68+
@include common.ell;
69+
70+
font-size: 13px;
71+
font-weight: 600;
72+
line-height: 1;
73+
}
74+
2775
.title_wrapper {
2876
position: absolute;
2977
left: 50%;

0 commit comments

Comments
 (0)