mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
fix(desktop): macos quit flash, first click and press feedback
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
// Source-contract guard for shared button press feedback.
|
||||
|
||||
// Overview: desktop click responsiveness depends on buttons giving immediate
|
||||
// visual feedback while pressed. DOM `click` only fires on pointer-up, so a
|
||||
// press with zero visual delta reads as "the click did nothing" and users
|
||||
// retry or hold the button (~1s holds measured in click-probe sessions).
|
||||
//
|
||||
// Two guarantees keep that regression from re-appearing:
|
||||
// 1. Every button variant styles an `active:` pressed state, so pressing
|
||||
// always changes something on screen before pointer-up.
|
||||
// 2. The pressed state must not move or scale the button (`scale-*` /
|
||||
// `translate-*`). A moving hit target can land pointer-up outside the
|
||||
// element it was pressed on, which genuinely drops the click.
|
||||
// Keep these assertions updated alongside the variant class strings.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const buttonVariantsSource = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
|
||||
|
||||
const VARIANTS = ["default", "outline", "secondary", "ghost", "destructive", "link"] as const;
|
||||
|
||||
function variantClasses(variant: string): string {
|
||||
const match = buttonVariantsSource.match(new RegExp(`${variant}:\\s*"([^"]+)"`));
|
||||
expect(match, `variant "${variant}" exists in buttonVariants`).toBeTruthy();
|
||||
return match![1];
|
||||
}
|
||||
|
||||
describe("button press feedback guard", () => {
|
||||
it.each(VARIANTS)("styles an active pressed state for the %s variant", (variant) => {
|
||||
expect(variantClasses(variant)).toMatch(/(^|\s)active:/);
|
||||
});
|
||||
|
||||
it.each(VARIANTS)("does not move the hit target while %s is pressed", (variant) => {
|
||||
expect(variantClasses(variant)).not.toMatch(/active:(scale|translate|-translate)-/);
|
||||
});
|
||||
|
||||
it("does not move the hit target from the shared base classes", () => {
|
||||
const base = buttonVariantsSource.match(/cva\(\s*"([^"]+)"/);
|
||||
expect(base).toBeTruthy();
|
||||
expect(base![1]).not.toMatch(/active:(scale|translate|-translate)-/);
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,12 @@ export const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80 [a]:active:bg-primary/90 active:bg-primary/90",
|
||||
outline: "border-border bg-background hover:bg-muted hover:text-foreground active:bg-accent active:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 active:bg-secondary aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost: "hover:bg-muted hover:text-foreground active:bg-accent active:text-accent-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive: "bg-destructive/10 hover:bg-destructive/20 active:bg-destructive/30 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30 dark:active:bg-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline active:opacity-80",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
|
||||
@@ -86,6 +86,15 @@ pub async fn complete_app_close(app: AppHandle, window: Window, action: String)
|
||||
if let Some(state) = app.try_state::<CloseBehaviorState>() {
|
||||
state.allow_next_exit();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Take the whole app off-screen first so WKWebView teardown during
|
||||
// process exit cannot flash the window red on macOS 12. `AppHandle::hide`
|
||||
// is macOS-only, so this block is compiled per-platform.
|
||||
let _ = window.hide();
|
||||
let _ = app.hide();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(crate::EXIT_HIDE_GRACE_MS)).await;
|
||||
}
|
||||
app.exit(0);
|
||||
}
|
||||
"hide" => {
|
||||
|
||||
+31
-4
@@ -115,6 +115,18 @@ fn should_hide_window_on_close(target_os: &str) -> bool {
|
||||
matches!(target_os, "macos" | "windows")
|
||||
}
|
||||
|
||||
/// How long to keep the app off-screen after hiding it and before the process
|
||||
/// exits, so WindowServer has removed the window before WKWebView teardown.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) const EXIT_HIDE_GRACE_MS: u64 = 250;
|
||||
|
||||
/// On macOS, tearing down WKWebView while the window is still on screen can
|
||||
/// paint the window red for a frame before the process exits, so the app is
|
||||
/// hidden first. Windows and Linux keep their existing exit behavior.
|
||||
fn should_hide_window_before_exit(target_os: &str) -> bool {
|
||||
target_os == "macos"
|
||||
}
|
||||
|
||||
fn should_setup_desktop_tray(target_os: &str, show_tray_icon: bool, linux_appindicator_available: bool) -> bool {
|
||||
show_tray_icon
|
||||
&& (matches!(target_os, "macos" | "windows") || (target_os == "linux" && linux_appindicator_available))
|
||||
@@ -973,10 +985,10 @@ mod tests {
|
||||
linux_drm_render_devices_from_paths, linux_nvidia_driver_from_state, linux_pci_id_from_sysfs_value,
|
||||
linux_selected_drm_render_device, linux_uses_native_wayland, linux_webkit_environment_override,
|
||||
linux_webkit_rendering_workarounds, native_window_decorations_override, should_confirm_app_exit_request,
|
||||
should_enable_single_instance, should_fallback_to_native_quit, should_hide_window_on_close,
|
||||
should_setup_desktop_tray, should_show_main_window_after_setup, should_show_main_window_before_setup_tasks,
|
||||
startup_data_dir_mode, tray_menu_labels_for_locale, uses_application_level_icon, LinuxDrmRenderDevice,
|
||||
LinuxNvidiaDriver,
|
||||
should_enable_single_instance, should_fallback_to_native_quit, should_hide_window_before_exit,
|
||||
should_hide_window_on_close, should_setup_desktop_tray, should_show_main_window_after_setup,
|
||||
should_show_main_window_before_setup_tasks, startup_data_dir_mode, tray_menu_labels_for_locale,
|
||||
uses_application_level_icon, LinuxDrmRenderDevice, LinuxNvidiaDriver,
|
||||
};
|
||||
use crate::data_dir::DataDirMode;
|
||||
use std::ffi::OsStr;
|
||||
@@ -1027,6 +1039,13 @@ mod tests {
|
||||
assert!(!should_hide_window_on_close("linux"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hides_window_before_exit_only_on_macos() {
|
||||
assert!(should_hide_window_before_exit("macos"));
|
||||
assert!(!should_hide_window_before_exit("windows"));
|
||||
assert!(!should_hide_window_before_exit("linux"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sets_up_desktop_tray_for_windows_macos_and_linux() {
|
||||
assert!(should_setup_desktop_tray("windows", true, false));
|
||||
@@ -2528,6 +2547,14 @@ pub fn run() {
|
||||
api.prevent_exit();
|
||||
request_app_close(app_handle, "quit");
|
||||
} else {
|
||||
// Restart exits and the no-frontend native quit bypass
|
||||
// `complete_app_close`, so hide the window here too; the
|
||||
// shutdown below gives WindowServer time to remove it.
|
||||
if should_hide_window_before_exit(std::env::consts::OS) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
tauri::async_runtime::block_on(async {
|
||||
if let Some(server) = app_handle.try_state::<commands::redis_pubsub_server::PubSubServerState>()
|
||||
{
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"x": 16,
|
||||
"y": 18
|
||||
},
|
||||
"hiddenTitle": true
|
||||
"hiddenTitle": true,
|
||||
"acceptFirstMouse": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
Reference in New Issue
Block a user