diff --git a/Cargo.lock b/Cargo.lock
index 777d87841..7652aa1d8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -13911,6 +13911,10 @@ dependencies = [
name = "tauri-plugin-windows"
version = "0.1.0"
dependencies = [
+ "objc2 0.6.1",
+ "objc2-app-kit 0.3.1",
+ "objc2-foundation 0.3.1",
+ "once_cell",
"serde",
"specta",
"specta-typescript",
@@ -13922,6 +13926,7 @@ dependencies = [
"tauri-plugin-auth",
"tauri-specta",
"thiserror 2.0.12",
+ "tokio",
"tracing",
"uuid",
]
diff --git a/apps/desktop/index.html b/apps/desktop/index.html
index bd431afc4..9519f07fe 100644
--- a/apps/desktop/index.html
+++ b/apps/desktop/index.html
@@ -8,6 +8,12 @@
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
Hyprnote
+
diff --git a/apps/desktop/src/components/editor-area/note-header/listen-button.tsx b/apps/desktop/src/components/editor-area/note-header/listen-button.tsx
index 45468d03c..4cf15c07d 100644
--- a/apps/desktop/src/components/editor-area/note-header/listen-button.tsx
+++ b/apps/desktop/src/components/editor-area/note-header/listen-button.tsx
@@ -197,8 +197,6 @@ function WhenActive() {
pause: s.pause,
stop: s.stop,
}));
- const audioControls = useAudioControls();
-
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const handlePauseSession = () => {
@@ -226,7 +224,6 @@ function WhenActive() {
@@ -236,25 +233,36 @@ function WhenActive() {
}
function RecordingControls({
- audioControls,
onPause,
onStop,
}: {
- audioControls: ReturnType;
onPause: () => void;
onStop: () => void;
}) {
+ const ongoingSessionMuted = useOngoingSession((s) => ({
+ micMuted: s.micMuted,
+ speakerMuted: s.speakerMuted,
+ }));
+
+ const toggleMicMuted = useMutation({
+ mutationFn: () => listenerCommands.setMicMuted(!ongoingSessionMuted.micMuted),
+ });
+
+ const toggleSpeakerMuted = useMutation({
+ mutationFn: () => listenerCommands.setSpeakerMuted(!ongoingSessionMuted.speakerMuted),
+ });
+
return (
<>
audioControls.toggleMicMuted.mutate()}
+ isMuted={ongoingSessionMuted.micMuted}
+ onClick={() => toggleMicMuted.mutate()}
type="mic"
/>
audioControls.toggleSpeakerMuted.mutate()}
+ isMuted={ongoingSessionMuted.speakerMuted}
+ onClick={() => toggleSpeakerMuted.mutate()}
type="speaker"
/>
@@ -313,34 +321,3 @@ function AudioControlButton({
);
}
-
-function useAudioControls() {
- const { data: isMicMuted, refetch: refetchMicMuted } = useQuery({
- queryKey: ["mic-muted"],
- queryFn: () => listenerCommands.getMicMuted(),
- });
-
- const { data: isSpeakerMuted, refetch: refetchSpeakerMuted } = useQuery({
- queryKey: ["speaker-muted"],
- queryFn: () => listenerCommands.getSpeakerMuted(),
- });
-
- const toggleMicMuted = useMutation({
- mutationFn: () => listenerCommands.setMicMuted(!isMicMuted),
- onSuccess: () => refetchMicMuted(),
- });
-
- const toggleSpeakerMuted = useMutation({
- mutationFn: () => listenerCommands.setSpeakerMuted(!isSpeakerMuted),
- onSuccess: () => refetchSpeakerMuted(),
- });
-
- return {
- isMicMuted,
- isSpeakerMuted,
- toggleMicMuted,
- toggleSpeakerMuted,
- refetchSpeakerMuted,
- refetchMicMuted,
- };
-}
diff --git a/apps/desktop/src/routeTree.gen.ts b/apps/desktop/src/routeTree.gen.ts
index 4c9dddbd9..b210add37 100644
--- a/apps/desktop/src/routeTree.gen.ts
+++ b/apps/desktop/src/routeTree.gen.ts
@@ -17,6 +17,7 @@ import { Route as AppIndexImport } from './routes/app.index'
import { Route as AppSettingsImport } from './routes/app.settings'
import { Route as AppPlansImport } from './routes/app.plans'
import { Route as AppNewImport } from './routes/app.new'
+import { Route as AppControlImport } from './routes/app.control'
import { Route as AppCalendarImport } from './routes/app.calendar'
import { Route as AppOrganizationIdImport } from './routes/app.organization.$id'
import { Route as AppNoteIdImport } from './routes/app.note.$id'
@@ -61,6 +62,12 @@ const AppNewRoute = AppNewImport.update({
getParentRoute: () => AppRoute,
} as any)
+const AppControlRoute = AppControlImport.update({
+ id: '/control',
+ path: '/control',
+ getParentRoute: () => AppRoute,
+} as any)
+
const AppCalendarRoute = AppCalendarImport.update({
id: '/calendar',
path: '/calendar',
@@ -116,6 +123,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppCalendarImport
parentRoute: typeof AppImport
}
+ '/app/control': {
+ id: '/app/control'
+ path: '/control'
+ fullPath: '/app/control'
+ preLoaderRoute: typeof AppControlImport
+ parentRoute: typeof AppImport
+ }
'/app/new': {
id: '/app/new'
path: '/new'
@@ -179,6 +193,7 @@ declare module '@tanstack/react-router' {
interface AppRouteChildren {
AppCalendarRoute: typeof AppCalendarRoute
+ AppControlRoute: typeof AppControlRoute
AppNewRoute: typeof AppNewRoute
AppPlansRoute: typeof AppPlansRoute
AppSettingsRoute: typeof AppSettingsRoute
@@ -191,6 +206,7 @@ interface AppRouteChildren {
const AppRouteChildren: AppRouteChildren = {
AppCalendarRoute: AppCalendarRoute,
+ AppControlRoute: AppControlRoute,
AppNewRoute: AppNewRoute,
AppPlansRoute: AppPlansRoute,
AppSettingsRoute: AppSettingsRoute,
@@ -207,6 +223,7 @@ export interface FileRoutesByFullPath {
'/app': typeof AppRouteWithChildren
'/video': typeof VideoRoute
'/app/calendar': typeof AppCalendarRoute
+ '/app/control': typeof AppControlRoute
'/app/new': typeof AppNewRoute
'/app/plans': typeof AppPlansRoute
'/app/settings': typeof AppSettingsRoute
@@ -220,6 +237,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/video': typeof VideoRoute
'/app/calendar': typeof AppCalendarRoute
+ '/app/control': typeof AppControlRoute
'/app/new': typeof AppNewRoute
'/app/plans': typeof AppPlansRoute
'/app/settings': typeof AppSettingsRoute
@@ -235,6 +253,7 @@ export interface FileRoutesById {
'/app': typeof AppRouteWithChildren
'/video': typeof VideoRoute
'/app/calendar': typeof AppCalendarRoute
+ '/app/control': typeof AppControlRoute
'/app/new': typeof AppNewRoute
'/app/plans': typeof AppPlansRoute
'/app/settings': typeof AppSettingsRoute
@@ -251,6 +270,7 @@ export interface FileRouteTypes {
| '/app'
| '/video'
| '/app/calendar'
+ | '/app/control'
| '/app/new'
| '/app/plans'
| '/app/settings'
@@ -263,6 +283,7 @@ export interface FileRouteTypes {
to:
| '/video'
| '/app/calendar'
+ | '/app/control'
| '/app/new'
| '/app/plans'
| '/app/settings'
@@ -276,6 +297,7 @@ export interface FileRouteTypes {
| '/app'
| '/video'
| '/app/calendar'
+ | '/app/control'
| '/app/new'
| '/app/plans'
| '/app/settings'
@@ -315,6 +337,7 @@ export const routeTree = rootRoute
"filePath": "app.tsx",
"children": [
"/app/calendar",
+ "/app/control",
"/app/new",
"/app/plans",
"/app/settings",
@@ -332,6 +355,10 @@ export const routeTree = rootRoute
"filePath": "app.calendar.tsx",
"parent": "/app"
},
+ "/app/control": {
+ "filePath": "app.control.tsx",
+ "parent": "/app"
+ },
"/app/new": {
"filePath": "app.new.tsx",
"parent": "/app"
diff --git a/apps/desktop/src/routes/__root.tsx b/apps/desktop/src/routes/__root.tsx
index 2db1ab69c..3fdba946c 100644
--- a/apps/desktop/src/routes/__root.tsx
+++ b/apps/desktop/src/routes/__root.tsx
@@ -3,6 +3,7 @@ import { scan } from "react-scan";
import { useQuery } from "@tanstack/react-query";
import { CatchNotFound, createRootRouteWithContext, Outlet, useNavigate } from "@tanstack/react-router";
+import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { lazy, Suspense, useEffect } from "react";
@@ -67,6 +68,19 @@ function Component() {
scan({ enabled: true });
}, []);
+ // Listen for debug events from control window
+ useEffect(() => {
+ let unlisten: (() => void) | undefined;
+
+ listen("debug", (event) => {
+ console.log(`[Control Debug] ${event.payload}`);
+ }).then((fn) => {
+ unlisten = fn;
+ });
+
+ return () => unlisten?.();
+ }, []);
+
return (
diff --git a/apps/desktop/src/routes/app.control.tsx b/apps/desktop/src/routes/app.control.tsx
new file mode 100644
index 000000000..19d14012f
--- /dev/null
+++ b/apps/desktop/src/routes/app.control.tsx
@@ -0,0 +1,532 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { emit } from "@tauri-apps/api/event";
+import { Circle, Grip, Mic, MicOff, Square, Volume2, VolumeX } from "lucide-react";
+import React, { useEffect, useRef, useState } from "react";
+
+import { commands as listenerCommands, events as listenerEvents } from "@hypr/plugin-listener";
+import { commands as windowsCommands } from "@hypr/plugin-windows";
+
+export const Route = createFileRoute("/app/control")({
+ component: Component,
+});
+
+function Component() {
+ const [position, setPosition] = useState(() => {
+ const savedPosition = localStorage.getItem("floating-control-position");
+ if (savedPosition) {
+ try {
+ const parsed = JSON.parse(savedPosition);
+ const windowWidth = window.innerWidth;
+ const windowHeight = window.innerHeight;
+ if (
+ parsed.x >= 0 && parsed.x <= windowWidth - 200
+ && parsed.y >= 0 && parsed.y <= windowHeight - 100
+ ) {
+ return parsed;
+ }
+ } catch (e) {
+ console.warn("Failed to parse saved position:", e);
+ }
+ }
+
+ const windowWidth = window.innerWidth;
+ const windowHeight = window.innerHeight;
+ const initialX = (windowWidth - 200) / 2;
+ const initialY = (windowHeight - 200) / 2;
+
+ return { x: initialX, y: initialY };
+ });
+
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
+
+ // Use refs to store current values for event handlers
+ const isDraggingRef = useRef(false);
+ const dragOffsetRef = useRef({ x: 0, y: 0 });
+
+ // Interaction tracking (lifted to component scope)
+ const lastInteractionRef = React.useRef(Date.now());
+ const trackInteraction = React.useCallback(() => {
+ lastInteractionRef.current = Date.now();
+ }, []);
+
+ // Update refs whenever state changes
+ useEffect(() => {
+ isDraggingRef.current = isDragging;
+ }, [isDragging]);
+
+ useEffect(() => {
+ dragOffsetRef.current = dragOffset;
+ }, [dragOffset]);
+
+ // Recording state from listener plugin
+ const [recordingStatus, setRecordingStatus] = useState<"inactive" | "running_active" | "running_paused">("inactive");
+ const [recordingLoading, setRecordingLoading] = useState(false);
+
+ // Audio controls state
+ const [micMuted, setMicMuted] = useState(false);
+ const [speakerMuted, setSpeakerMuted] = useState(false);
+
+ const isRecording = recordingStatus !== "inactive";
+ const isRecordingActive = recordingStatus === "running_active";
+ const isRecordingPaused = recordingStatus === "running_paused";
+
+ // Load initial recording state and listen for changes
+ useEffect(() => {
+ const initializeState = async () => {
+ try {
+ // Get initial state from listener plugin
+ const currentState = await listenerCommands.getState();
+ console.log(`[Control Bar] Initial state: ${currentState}`);
+
+ if (currentState === "running_active" || currentState === "running_paused" || currentState === "inactive") {
+ setRecordingStatus(currentState as any);
+ }
+
+ // Get initial audio state
+ const [initialMicMuted, initialSpeakerMuted] = await Promise.all([
+ listenerCommands.getMicMuted(),
+ listenerCommands.getSpeakerMuted(),
+ ]);
+ setMicMuted(initialMicMuted);
+ setSpeakerMuted(initialSpeakerMuted);
+ } catch (error) {
+ console.error("[Control Bar] Failed to load initial state:", error);
+ }
+ };
+
+ initializeState();
+
+ const unsubscribeSession = listenerEvents.sessionEvent.listen(({ payload }) => {
+ if (payload.type === "inactive" || payload.type === "running_active" || payload.type === "running_paused") {
+ setRecordingStatus(payload.type);
+ setRecordingLoading(false);
+ }
+
+ if (payload.type === "micMuted") {
+ setMicMuted(payload.value);
+ }
+
+ if (payload.type === "speakerMuted") {
+ setSpeakerMuted(payload.value);
+ }
+ });
+
+ return () => {
+ unsubscribeSession.then(unlisten => unlisten());
+ };
+ }, []);
+
+ // Debug logging
+ useEffect(() => {
+ console.log(
+ `[Control Bar Debug] Recording status: ${recordingStatus}, isRecording: ${isRecording}, isRecordingActive: ${isRecordingActive}`,
+ );
+ }, [recordingStatus, isRecording, isRecordingActive]);
+
+ const controlRef = useRef(null) as React.MutableRefObject;
+ const toolbarRef = useRef(null);
+ const boundsUpdateTimeoutRef = useRef(null);
+
+ const setControlRef = (el: HTMLDivElement | null) => {
+ if (el) {
+ setTimeout(() => {
+ const rect = el.getBoundingClientRect();
+ const actualPosition = { x: rect.left, y: rect.top };
+
+ const threshold = 10;
+ if (
+ Math.abs(actualPosition.x - position.x) > threshold
+ || Math.abs(actualPosition.y - position.y) > threshold
+ ) {
+ setPosition(actualPosition);
+ }
+ }, 50);
+ }
+ controlRef.current = el;
+ };
+
+ const updateOverlayBounds = async () => {
+ if (toolbarRef.current) {
+ const toolbarRect = toolbarRef.current.getBoundingClientRect();
+
+ let bounds = {
+ x: position.x,
+ y: position.y,
+ width: toolbarRect.width,
+ height: toolbarRect.height,
+ };
+
+ emit("debug", `Toolbar position: ${JSON.stringify(position)}`);
+ emit(
+ "debug",
+ `Toolbar rect: ${
+ JSON.stringify({ x: toolbarRect.x, y: toolbarRect.y, width: toolbarRect.width, height: toolbarRect.height })
+ }`,
+ );
+ emit("debug", `Setting overlay bounds: ${JSON.stringify(bounds)}`);
+ emit("debug", `Window dimensions: ${JSON.stringify({ width: window.innerWidth, height: window.innerHeight })}`);
+
+ try {
+ await windowsCommands.setFakeWindowBounds("control", bounds);
+ } catch (error) {
+ console.error("Failed to set fake window bounds:", error);
+ }
+ }
+ };
+
+ // Debounced version to prevent excessive bounds updates
+ const debouncedUpdateBounds = () => {
+ if (boundsUpdateTimeoutRef.current) {
+ clearTimeout(boundsUpdateTimeoutRef.current);
+ }
+ boundsUpdateTimeoutRef.current = window.setTimeout(() => {
+ updateOverlayBounds();
+ boundsUpdateTimeoutRef.current = null;
+ }, 100); // Increased debounce delay to 100ms for better stability
+ };
+
+ const handleToolbarClick = (e: React.MouseEvent) => {
+ // Don't stop propagation to allow drag events to work properly
+ };
+
+ useEffect(() => {
+ // Immediately set transparent background to prevent white flash
+ document.body.style.background = "transparent";
+ document.body.style.backgroundColor = "transparent";
+ document.documentElement.style.background = "transparent";
+ document.documentElement.style.backgroundColor = "transparent";
+ document.documentElement.setAttribute("data-transparent-window", "true");
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!isDraggingRef.current) {
+ return;
+ }
+
+ // Get toolbar dimensions for clamping
+ const toolbarWidth = toolbarRef.current?.getBoundingClientRect().width || 200;
+ const toolbarHeight = toolbarRef.current?.getBoundingClientRect().height || 60;
+
+ // Clamp position to keep toolbar on screen
+ const clampedX = Math.max(0, Math.min(window.innerWidth - toolbarWidth, e.clientX - dragOffsetRef.current.x));
+ const clampedY = Math.max(0, Math.min(window.innerHeight - toolbarHeight, e.clientY - dragOffsetRef.current.y));
+
+ const newPosition = {
+ x: clampedX,
+ y: clampedY,
+ };
+
+ setPosition(newPosition);
+ };
+
+ const handleMouseUp = () => {
+ setIsDragging(false);
+ // Force bounds update after drag completes
+ setTimeout(() => {
+ updateOverlayBounds();
+ }, 50);
+ };
+
+ // Handle desktop switching and window focus changes on Mac
+ const handleWindowFocus = () => {
+ // Smart recovery on focus - only aggressive if needed
+ smartRecovery();
+ };
+
+ const handleVisibilityChange = () => {
+ if (!document.hidden) {
+ // Smart recovery on visibility change
+ smartRecovery();
+ }
+ };
+
+ const handleWindowResize = () => {
+ debouncedUpdateBounds();
+ };
+
+ const smartRecovery = () => {
+ const timeSinceInteraction = Date.now() - lastInteractionRef.current;
+ if (timeSinceInteraction > 10000) { // 10 seconds of no interaction
+ windowsCommands.removeFakeWindow("control").then(() => {
+ setTimeout(updateOverlayBounds, 100);
+ }).catch(console.error);
+ } else {
+ // Just do a simple bounds update
+ updateOverlayBounds();
+ }
+ trackInteraction();
+ };
+
+ window.addEventListener("mousemove", handleMouseMove);
+ window.addEventListener("mouseup", handleMouseUp);
+ window.addEventListener("focus", handleWindowFocus);
+ window.addEventListener("resize", handleWindowResize);
+ document.addEventListener("visibilitychange", handleVisibilityChange);
+
+ // Initial bounds setup - use longer delay to ensure DOM is ready and position is loaded
+ setTimeout(() => {
+ updateOverlayBounds();
+ }, 200);
+
+ return () => {
+ window.removeEventListener("mousemove", handleMouseMove);
+ window.removeEventListener("mouseup", handleMouseUp);
+ window.removeEventListener("focus", handleWindowFocus);
+ window.removeEventListener("resize", handleWindowResize);
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
+ if (boundsUpdateTimeoutRef.current) {
+ clearTimeout(boundsUpdateTimeoutRef.current);
+ }
+ windowsCommands.removeFakeWindow("control");
+ };
+ }, []); // Remove dependencies to prevent re-creating event listeners
+
+ useEffect(() => {
+ // Update bounds whenever position changes (safety mechanism)
+ debouncedUpdateBounds();
+
+ // Save position to localStorage for persistence across window recreations
+ localStorage.setItem("floating-control-position", JSON.stringify(position));
+ }, [position]);
+
+ // Effect to detect and sync to actual rendered position (handles window recreation)
+ useEffect(() => {
+ const detectActualPosition = () => {
+ if (controlRef.current) {
+ const rect = controlRef.current.getBoundingClientRect();
+ const actualPosition = { x: rect.left, y: rect.top };
+
+ // If there's a significant difference, sync React state to actual position
+ const threshold = 10; // pixels
+ if (
+ Math.abs(actualPosition.x - position.x) > threshold
+ || Math.abs(actualPosition.y - position.y) > threshold
+ ) {
+ setPosition(actualPosition);
+ } else {
+ // Positions match, just update bounds
+ updateOverlayBounds();
+ }
+ }
+ };
+
+ // Multiple attempts to catch the actual position
+ const timers = [100, 200, 500].map(delay => setTimeout(detectActualPosition, delay));
+
+ return () => timers.forEach(clearTimeout);
+ }, []); // Only run once on mount
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ setIsDragging(true);
+ setDragOffset({
+ x: e.clientX - position.x,
+ y: e.clientY - position.y,
+ });
+ };
+
+ const toggleRecording = async () => {
+ try {
+ setRecordingLoading(true);
+
+ if (isRecording) {
+ if (isRecordingActive) {
+ await listenerCommands.stopSession();
+ } else if (isRecordingPaused) {
+ await listenerCommands.resumeSession();
+ }
+ } else {
+ // Create a new session and start recording
+ const newSessionId = `control-session-${Date.now()}`;
+ await listenerCommands.startSession(newSessionId);
+ }
+ } catch (error) {
+ console.error("[Control Bar] Recording error:", error);
+ } finally {
+ setRecordingLoading(false);
+ }
+ };
+
+ const pauseRecording = async () => {
+ try {
+ setRecordingLoading(true);
+ if (isRecordingActive) {
+ await listenerCommands.pauseSession();
+ }
+ } catch (error) {
+ console.error("[Control Bar] Pause error:", error);
+ } finally {
+ setRecordingLoading(false);
+ }
+ };
+
+ const toggleMic = async () => {
+ try {
+ const newMuted = !micMuted;
+ await listenerCommands.setMicMuted(newMuted);
+ setMicMuted(newMuted);
+ // Emit event to synchronize with other windows
+ await emit("audio-mic-state-changed", { muted: newMuted });
+ console.log(`[Control Bar] ${newMuted ? "Muted" : "Unmuted"} microphone`);
+ } catch (error) {
+ console.error("[Control Bar] Mic toggle error:", error);
+ }
+ };
+
+ const toggleSpeaker = async () => {
+ try {
+ const newMuted = !speakerMuted;
+ await listenerCommands.setSpeakerMuted(newMuted);
+ setSpeakerMuted(newMuted);
+ // Emit event to synchronize with other windows
+ await emit("audio-speaker-state-changed", { muted: newMuted });
+ console.log(`[Control Bar] ${newMuted ? "Muted" : "Unmuted"} speaker`);
+ } catch (error) {
+ console.error("[Control Bar] Speaker toggle error:", error);
+ }
+ };
+
+ return (
+
+
+
{
+ // Lightweight hover recovery
+ trackInteraction();
+ updateOverlayBounds();
+ }}
+ style={{
+ pointerEvents: "auto",
+ background: "rgba(0, 0, 0, 0.85)",
+ boxShadow: "0 8px 32px 0 rgba(0, 0, 0, 0.6)",
+ }}
+ >
+
+ {/* Section 1: Mic + Speaker */}
+
+
+ {micMuted ? : }
+
+
+
+ {speakerMuted ? : }
+
+
+
+
+
+ {/* Section 2: Pause + Stop */}
+
+ {/* Pause/Resume Button */}
+ {isRecording && (
+
+ {recordingLoading
+ ?
+ : isRecordingActive
+ ? (
+
+ )
+ : }
+
+ )}
+
+ {/* Stop/Start Button */}
+
+ {recordingLoading
+ ?
+ : isRecording
+ ?
+ : }
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function IconButton({ onClick, children, className = "", tooltip = "", disabled = false }: {
+ onClick?: ((e: React.MouseEvent) => void) | (() => void);
+ children: React.ReactNode;
+ className?: string;
+ tooltip?: string;
+ disabled?: boolean;
+}) {
+ const handleClick = (e: React.MouseEvent) => {
+ e.stopPropagation(); // Prevent button clicks from triggering drag
+ if (!disabled) {
+ onClick?.(e);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/utils/src/stores/ongoing-session.ts b/packages/utils/src/stores/ongoing-session.ts
index 1655fcfb1..34e1b624f 100644
--- a/packages/utils/src/stores/ongoing-session.ts
+++ b/packages/utils/src/stores/ongoing-session.ts
@@ -11,6 +11,8 @@ type State = {
status: "inactive" | "running_active" | "running_paused";
amplitude: { mic: number; speaker: number };
enhanceController: AbortController | null;
+ micMuted: boolean;
+ speakerMuted: boolean;
};
type Actions = {
@@ -29,6 +31,8 @@ const initialState: State = {
loading: false,
amplitude: { mic: 0, speaker: 0 },
enhanceController: null,
+ micMuted: false,
+ speakerMuted: false,
};
export type OngoingSessionStore = ReturnType;
@@ -71,6 +75,39 @@ export const createOngoingSessionStore = (sessionsStore: ReturnType
+ mutate(state, (draft) => {
+ draft.status = "running_active";
+ draft.loading = false;
+ })
+ );
+ } else if (payload.type === "running_paused") {
+ set((state) =>
+ mutate(state, (draft) => {
+ draft.status = "running_paused";
+ draft.loading = false;
+ })
+ );
+ } else if (payload.type === "inactive") {
+ set((state) =>
+ mutate(state, (draft) => {
+ draft.status = "inactive";
+ draft.loading = false;
+ })
+ );
+ } else if (payload.type === "micMuted") {
+ set((state) =>
+ mutate(state, (draft) => {
+ draft.micMuted = payload.value;
+ })
+ );
+ } else if (payload.type === "speakerMuted") {
+ set((state) =>
+ mutate(state, (draft) => {
+ draft.speakerMuted = payload.value;
+ })
+ );
}
}).then((unlisten) => {
set((state) =>
@@ -90,6 +127,12 @@ export const createOngoingSessionStore = (sessionsStore: ReturnType {
const { sessionId } = get();
+ set((state) =>
+ mutate(state, (draft) => {
+ draft.loading = true;
+ })
+ );
+
listenerCommands.stopSession().then(() => {
set(initialState);
@@ -101,13 +144,31 @@ export const createOngoingSessionStore = (sessionsStore: ReturnType {
+ console.error("Failed to stop session:", error);
+ set((state) =>
+ mutate(state, (draft) => {
+ draft.loading = false;
+ })
+ );
});
},
pause: () => {
const { sessionId } = get();
+ set((state) =>
+ mutate(state, (draft) => {
+ draft.loading = true;
+ })
+ );
+
listenerCommands.pauseSession().then(() => {
- set({ status: "running_paused" });
+ set((state) =>
+ mutate(state, (draft) => {
+ draft.status = "running_paused";
+ draft.loading = false;
+ })
+ );
// We need refresh since session in store is now stale.
// setTimeout is needed because of debounce.
@@ -117,11 +178,36 @@ export const createOngoingSessionStore = (sessionsStore: ReturnType {
+ console.error("Failed to pause session:", error);
+ set((state) =>
+ mutate(state, (draft) => {
+ draft.loading = false;
+ })
+ );
});
},
resume: () => {
+ set((state) =>
+ mutate(state, (draft) => {
+ draft.loading = true;
+ })
+ );
+
listenerCommands.resumeSession().then(() => {
- set({ status: "running_active" });
+ set((state) =>
+ mutate(state, (draft) => {
+ draft.status = "running_active";
+ draft.loading = false;
+ })
+ );
+ }).catch((error) => {
+ console.error("Failed to resume session:", error);
+ set((state) =>
+ mutate(state, (draft) => {
+ draft.loading = false;
+ })
+ );
});
},
}));
diff --git a/plugins/listener/js/bindings.gen.ts b/plugins/listener/js/bindings.gen.ts
index 16a9955d5..da49cfd16 100644
--- a/plugins/listener/js/bindings.gen.ts
+++ b/plugins/listener/js/bindings.gen.ts
@@ -72,7 +72,7 @@ sessionEvent: "plugin:listener:session-event"
/** user-defined types **/
-export type SessionEvent = { type: "inactive" } | { type: "running_active" } | { type: "running_paused" } | { type: "words"; words: Word[] } | { type: "audioAmplitude"; mic: number; speaker: number }
+export type SessionEvent = { type: "inactive" } | { type: "running_active" } | { type: "running_paused" } | { type: "words"; words: Word[] } | { type: "audioAmplitude"; mic: number; speaker: number } | { type: "micMuted"; value: boolean } | { type: "speakerMuted"; value: boolean }
export type SpeakerIdentity = { type: "unassigned"; value: { index: number } } | { type: "assigned"; value: { id: string; label: string } }
export type Word = { text: string; speaker: SpeakerIdentity | null; confidence: number | null; start_ms: number | null; end_ms: number | null }
diff --git a/plugins/listener/src/events.rs b/plugins/listener/src/events.rs
index 23f3a3085..1ba1dcb10 100644
--- a/plugins/listener/src/events.rs
+++ b/plugins/listener/src/events.rs
@@ -19,6 +19,10 @@ common_event_derives! {
Words { words: Vec},
#[serde(rename = "audioAmplitude")]
AudioAmplitude { mic: u16, speaker: u16 },
+ #[serde(rename = "micMuted")]
+ MicMuted { value: bool },
+ #[serde(rename = "speakerMuted")]
+ SpeakerMuted { value: bool },
}
}
diff --git a/plugins/listener/src/fsm.rs b/plugins/listener/src/fsm.rs
index 0ac8afcb9..e1ac4a151 100644
--- a/plugins/listener/src/fsm.rs
+++ b/plugins/listener/src/fsm.rs
@@ -403,12 +403,14 @@ impl Session {
StateEvent::MicMuted(muted) => {
if let Some(tx) = &self.mic_muted_tx {
let _ = tx.send(*muted);
+ let _ = SessionEvent::MicMuted { value: *muted }.emit(&self.app);
}
Handled
}
StateEvent::SpeakerMuted(muted) => {
if let Some(tx) = &self.speaker_muted_tx {
let _ = tx.send(*muted);
+ let _ = SessionEvent::SpeakerMuted { value: *muted }.emit(&self.app);
}
Handled
}
@@ -477,6 +479,11 @@ impl Session {
let _ = self.app.set_start_disabled(false);
}
+ {
+ use tauri_plugin_windows::{HyprWindow, WindowsPluginExt};
+ let _ = self.app.window_hide(HyprWindow::Control);
+ }
+
if let Some(session_id) = &self.session_id {
use tauri_plugin_db::DatabasePluginExt;
@@ -497,6 +504,11 @@ impl Session {
#[action]
async fn enter_running_active(&mut self) {
+ {
+ use tauri_plugin_windows::{HyprWindow, WindowsPluginExt};
+ let _ = self.app.window_show(HyprWindow::Control);
+ }
+
if let Some(session_id) = &self.session_id {
use tauri_plugin_db::DatabasePluginExt;
diff --git a/plugins/windows/Cargo.toml b/plugins/windows/Cargo.toml
index be22c1aa3..f084a9370 100644
--- a/plugins/windows/Cargo.toml
+++ b/plugins/windows/Cargo.toml
@@ -15,6 +15,9 @@ specta-typescript = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
tauri-nspanel = { workspace = true }
+objc2 = { workspace = true }
+objc2-app-kit = { workspace = true }
+objc2-foundation = { workspace = true }
[dependencies]
serde = { workspace = true }
@@ -28,5 +31,7 @@ tauri-specta = { workspace = true, features = ["derive", "typescript"] }
tauri-plugin-analytics = { workspace = true }
tauri-plugin-auth = { workspace = true }
+once_cell = { workspace = true }
thiserror = { workspace = true }
+tokio = { workspace = true, features = ["time"] }
tracing = { workspace = true }
diff --git a/plugins/windows/build.rs b/plugins/windows/build.rs
index 1d4b3cb05..6abb41018 100644
--- a/plugins/windows/build.rs
+++ b/plugins/windows/build.rs
@@ -1,5 +1,6 @@
const COMMANDS: &[&str] = &[
"window_show",
+ "window_close",
"window_hide",
"window_destroy",
"window_position",
@@ -8,7 +9,10 @@ const COMMANDS: &[&str] = &[
"window_navigate",
"window_emit_navigate",
"window_is_visible",
- "window_resize_default",
+ "window_set_overlay_bounds",
+ "window_remove_overlay_bounds",
+ "set_fake_window_bounds",
+ "remove_fake_window",
];
fn main() {
diff --git a/plugins/windows/js/bindings.gen.ts b/plugins/windows/js/bindings.gen.ts
index c90633abf..da93ff0ac 100644
--- a/plugins/windows/js/bindings.gen.ts
+++ b/plugins/windows/js/bindings.gen.ts
@@ -10,6 +10,9 @@ export const commands = {
async windowShow(window: HyprWindow) : Promise {
return await TAURI_INVOKE("plugin:windows|window_show", { window });
},
+async windowClose(window: HyprWindow) : Promise {
+ return await TAURI_INVOKE("plugin:windows|window_close", { window });
+},
async windowHide(window: HyprWindow) : Promise {
return await TAURI_INVOKE("plugin:windows|window_hide", { window });
},
@@ -19,9 +22,6 @@ async windowDestroy(window: HyprWindow) : Promise {
async windowPosition(window: HyprWindow, pos: KnownPosition) : Promise {
return await TAURI_INVOKE("plugin:windows|window_position", { window, pos });
},
-async windowResizeDefault(window: HyprWindow) : Promise {
- return await TAURI_INVOKE("plugin:windows|window_resize_default", { window });
-},
async windowGetFloating(window: HyprWindow) : Promise {
return await TAURI_INVOKE("plugin:windows|window_get_floating", { window });
},
@@ -36,6 +36,18 @@ async windowEmitNavigate(window: HyprWindow, path: string) : Promise {
},
async windowIsVisible(window: HyprWindow) : Promise {
return await TAURI_INVOKE("plugin:windows|window_is_visible", { window });
+},
+async windowSetOverlayBounds(name: string, bounds: OverlayBound) : Promise {
+ return await TAURI_INVOKE("plugin:windows|window_set_overlay_bounds", { name, bounds });
+},
+async windowRemoveOverlayBounds(name: string) : Promise {
+ return await TAURI_INVOKE("plugin:windows|window_remove_overlay_bounds", { name });
+},
+async setFakeWindowBounds(name: string, bounds: OverlayBound) : Promise {
+ return await TAURI_INVOKE("plugin:windows|set_fake_window_bounds", { name, bounds });
+},
+async removeFakeWindow(name: string) : Promise {
+ return await TAURI_INVOKE("plugin:windows|remove_fake_window", { name });
}
}
@@ -58,10 +70,11 @@ windowDestroyed: "plugin:windows:window-destroyed"
/** user-defined types **/
-export type HyprWindow = { type: "main" } | { type: "note"; value: string } | { type: "human"; value: string } | { type: "organization"; value: string } | { type: "calendar" } | { type: "settings" } | { type: "video"; value: string } | { type: "plans" }
+export type HyprWindow = { type: "main" } | { type: "note"; value: string } | { type: "human"; value: string } | { type: "organization"; value: string } | { type: "calendar" } | { type: "settings" } | { type: "video"; value: string } | { type: "plans" } | { type: "control" }
export type KnownPosition = "left-half" | "right-half" | "center"
export type MainWindowState = { left_sidebar_expanded: boolean | null; right_panel_expanded: boolean | null }
export type Navigate = { path: string }
+export type OverlayBound = { x: number; y: number; width: number; height: number }
export type WindowDestroyed = { window: HyprWindow }
/** tauri-specta globals **/
diff --git a/plugins/windows/permissions/autogenerated/commands/remove_fake_window.toml b/plugins/windows/permissions/autogenerated/commands/remove_fake_window.toml
new file mode 100644
index 000000000..57e96c689
--- /dev/null
+++ b/plugins/windows/permissions/autogenerated/commands/remove_fake_window.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-remove-fake-window"
+description = "Enables the remove_fake_window command without any pre-configured scope."
+commands.allow = ["remove_fake_window"]
+
+[[permission]]
+identifier = "deny-remove-fake-window"
+description = "Denies the remove_fake_window command without any pre-configured scope."
+commands.deny = ["remove_fake_window"]
diff --git a/plugins/windows/permissions/autogenerated/commands/set_fake_window_bounds.toml b/plugins/windows/permissions/autogenerated/commands/set_fake_window_bounds.toml
new file mode 100644
index 000000000..cc7a685aa
--- /dev/null
+++ b/plugins/windows/permissions/autogenerated/commands/set_fake_window_bounds.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-set-fake-window-bounds"
+description = "Enables the set_fake_window_bounds command without any pre-configured scope."
+commands.allow = ["set_fake_window_bounds"]
+
+[[permission]]
+identifier = "deny-set-fake-window-bounds"
+description = "Denies the set_fake_window_bounds command without any pre-configured scope."
+commands.deny = ["set_fake_window_bounds"]
diff --git a/plugins/windows/permissions/autogenerated/commands/window_close.toml b/plugins/windows/permissions/autogenerated/commands/window_close.toml
new file mode 100644
index 000000000..0f822878c
--- /dev/null
+++ b/plugins/windows/permissions/autogenerated/commands/window_close.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-window-close"
+description = "Enables the window_close command without any pre-configured scope."
+commands.allow = ["window_close"]
+
+[[permission]]
+identifier = "deny-window-close"
+description = "Denies the window_close command without any pre-configured scope."
+commands.deny = ["window_close"]
diff --git a/plugins/windows/permissions/autogenerated/commands/window_remove_overlay_bounds.toml b/plugins/windows/permissions/autogenerated/commands/window_remove_overlay_bounds.toml
new file mode 100644
index 000000000..29eeacda8
--- /dev/null
+++ b/plugins/windows/permissions/autogenerated/commands/window_remove_overlay_bounds.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-window-remove-overlay-bounds"
+description = "Enables the window_remove_overlay_bounds command without any pre-configured scope."
+commands.allow = ["window_remove_overlay_bounds"]
+
+[[permission]]
+identifier = "deny-window-remove-overlay-bounds"
+description = "Denies the window_remove_overlay_bounds command without any pre-configured scope."
+commands.deny = ["window_remove_overlay_bounds"]
diff --git a/plugins/windows/permissions/autogenerated/commands/window_set_overlay_bounds.toml b/plugins/windows/permissions/autogenerated/commands/window_set_overlay_bounds.toml
new file mode 100644
index 000000000..75d7dc625
--- /dev/null
+++ b/plugins/windows/permissions/autogenerated/commands/window_set_overlay_bounds.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-window-set-overlay-bounds"
+description = "Enables the window_set_overlay_bounds command without any pre-configured scope."
+commands.allow = ["window_set_overlay_bounds"]
+
+[[permission]]
+identifier = "deny-window-set-overlay-bounds"
+description = "Denies the window_set_overlay_bounds command without any pre-configured scope."
+commands.deny = ["window_set_overlay_bounds"]
diff --git a/plugins/windows/permissions/autogenerated/reference.md b/plugins/windows/permissions/autogenerated/reference.md
index 4c73cb197..9d1d03ba0 100644
--- a/plugins/windows/permissions/autogenerated/reference.md
+++ b/plugins/windows/permissions/autogenerated/reference.md
@@ -5,15 +5,19 @@ Default permissions for the plugin
#### This default permission set includes the following:
- `allow-window-show`
+- `allow-window-close`
- `allow-window-hide`
- `allow-window-destroy`
- `allow-window-position`
-- `allow-window-resize-default`
- `allow-window-get-floating`
- `allow-window-set-floating`
- `allow-window-navigate`
- `allow-window-emit-navigate`
- `allow-window-is-visible`
+- `allow-window-set-overlay-bounds`
+- `allow-window-remove-overlay-bounds`
+- `allow-set-fake-window-bounds`
+- `allow-remove-fake-window`
## Permission Table
@@ -24,6 +28,84 @@ Default permissions for the plugin
+
+
+
+`windows:allow-remove-fake-window`
+
+ |
+
+
+Enables the remove_fake_window command without any pre-configured scope.
+
+ |
+
+
+
+
+
+`windows:deny-remove-fake-window`
+
+ |
+
+
+Denies the remove_fake_window command without any pre-configured scope.
+
+ |
+
+
+
+
+
+`windows:allow-set-fake-window-bounds`
+
+ |
+
+
+Enables the set_fake_window_bounds command without any pre-configured scope.
+
+ |
+
+
+
+
+
+`windows:deny-set-fake-window-bounds`
+
+ |
+
+
+Denies the set_fake_window_bounds command without any pre-configured scope.
+
+ |
+
+
+
+
+
+`windows:allow-window-close`
+
+ |
+
+
+Enables the window_close command without any pre-configured scope.
+
+ |
+
+
+
+
+
+`windows:deny-window-close`
+
+ |
+
+
+Denies the window_close command without any pre-configured scope.
+
+ |
+
+
@@ -209,6 +291,32 @@ Denies the window_position command without any pre-configured scope.
|
+`windows:allow-window-remove-overlay-bounds`
+
+ |
+
+
+Enables the window_remove_overlay_bounds command without any pre-configured scope.
+
+ |
+
+
+
+
+
+`windows:deny-window-remove-overlay-bounds`
+
+ |
+
+
+Denies the window_remove_overlay_bounds command without any pre-configured scope.
+
+ |
+
+
+
+
+
`windows:allow-window-resize-default`
|
@@ -261,6 +369,32 @@ Denies the window_set_floating command without any pre-configured scope.
+`windows:allow-window-set-overlay-bounds`
+
+ |
+
+
+Enables the window_set_overlay_bounds command without any pre-configured scope.
+
+ |
+
+
+
+
+
+`windows:deny-window-set-overlay-bounds`
+
+ |
+
+
+Denies the window_set_overlay_bounds command without any pre-configured scope.
+
+ |
+
+
+
+
+
`windows:allow-window-show`
|
diff --git a/plugins/windows/permissions/default.toml b/plugins/windows/permissions/default.toml
index d72b93cd5..8788da817 100644
--- a/plugins/windows/permissions/default.toml
+++ b/plugins/windows/permissions/default.toml
@@ -2,13 +2,17 @@
description = "Default permissions for the plugin"
permissions = [
"allow-window-show",
- "allow-window-hide",
+ "allow-window-close",
+ "allow-window-hide",
"allow-window-destroy",
"allow-window-position",
- "allow-window-resize-default",
"allow-window-get-floating",
"allow-window-set-floating",
"allow-window-navigate",
"allow-window-emit-navigate",
"allow-window-is-visible",
+ "allow-window-set-overlay-bounds",
+ "allow-window-remove-overlay-bounds",
+ "allow-set-fake-window-bounds",
+ "allow-remove-fake-window",
]
diff --git a/plugins/windows/permissions/schemas/schema.json b/plugins/windows/permissions/schemas/schema.json
index cc865df44..8c999c38e 100644
--- a/plugins/windows/permissions/schemas/schema.json
+++ b/plugins/windows/permissions/schemas/schema.json
@@ -294,6 +294,42 @@
"PermissionKind": {
"type": "string",
"oneOf": [
+ {
+ "description": "Enables the remove_fake_window command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-remove-fake-window",
+ "markdownDescription": "Enables the remove_fake_window command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the remove_fake_window command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-remove-fake-window",
+ "markdownDescription": "Denies the remove_fake_window command without any pre-configured scope."
+ },
+ {
+ "description": "Enables the set_fake_window_bounds command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-set-fake-window-bounds",
+ "markdownDescription": "Enables the set_fake_window_bounds command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the set_fake_window_bounds command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-set-fake-window-bounds",
+ "markdownDescription": "Denies the set_fake_window_bounds command without any pre-configured scope."
+ },
+ {
+ "description": "Enables the window_close command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-window-close",
+ "markdownDescription": "Enables the window_close command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the window_close command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-window-close",
+ "markdownDescription": "Denies the window_close command without any pre-configured scope."
+ },
{
"description": "Enables the window_destroy command without any pre-configured scope.",
"type": "string",
@@ -378,6 +414,18 @@
"const": "deny-window-position",
"markdownDescription": "Denies the window_position command without any pre-configured scope."
},
+ {
+ "description": "Enables the window_remove_overlay_bounds command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-window-remove-overlay-bounds",
+ "markdownDescription": "Enables the window_remove_overlay_bounds command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the window_remove_overlay_bounds command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-window-remove-overlay-bounds",
+ "markdownDescription": "Denies the window_remove_overlay_bounds command without any pre-configured scope."
+ },
{
"description": "Enables the window_resize_default command without any pre-configured scope.",
"type": "string",
@@ -402,6 +450,18 @@
"const": "deny-window-set-floating",
"markdownDescription": "Denies the window_set_floating command without any pre-configured scope."
},
+ {
+ "description": "Enables the window_set_overlay_bounds command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-window-set-overlay-bounds",
+ "markdownDescription": "Enables the window_set_overlay_bounds command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the window_set_overlay_bounds command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-window-set-overlay-bounds",
+ "markdownDescription": "Denies the window_set_overlay_bounds command without any pre-configured scope."
+ },
{
"description": "Enables the window_show command without any pre-configured scope.",
"type": "string",
@@ -415,10 +475,10 @@
"markdownDescription": "Denies the window_show command without any pre-configured scope."
},
{
- "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-window-show`\n- `allow-window-hide`\n- `allow-window-destroy`\n- `allow-window-position`\n- `allow-window-resize-default`\n- `allow-window-get-floating`\n- `allow-window-set-floating`\n- `allow-window-navigate`\n- `allow-window-emit-navigate`\n- `allow-window-is-visible`",
+ "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-window-show`\n- `allow-window-close`\n- `allow-window-hide`\n- `allow-window-destroy`\n- `allow-window-position`\n- `allow-window-get-floating`\n- `allow-window-set-floating`\n- `allow-window-navigate`\n- `allow-window-emit-navigate`\n- `allow-window-is-visible`\n- `allow-window-set-overlay-bounds`\n- `allow-window-remove-overlay-bounds`\n- `allow-set-fake-window-bounds`\n- `allow-remove-fake-window`",
"type": "string",
"const": "default",
- "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-window-show`\n- `allow-window-hide`\n- `allow-window-destroy`\n- `allow-window-position`\n- `allow-window-resize-default`\n- `allow-window-get-floating`\n- `allow-window-set-floating`\n- `allow-window-navigate`\n- `allow-window-emit-navigate`\n- `allow-window-is-visible`"
+ "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-window-show`\n- `allow-window-close`\n- `allow-window-hide`\n- `allow-window-destroy`\n- `allow-window-position`\n- `allow-window-get-floating`\n- `allow-window-set-floating`\n- `allow-window-navigate`\n- `allow-window-emit-navigate`\n- `allow-window-is-visible`\n- `allow-window-set-overlay-bounds`\n- `allow-window-remove-overlay-bounds`\n- `allow-set-fake-window-bounds`\n- `allow-remove-fake-window`"
}
]
}
diff --git a/plugins/windows/src/commands.rs b/plugins/windows/src/commands.rs
index ec79b0d85..a4d28a614 100644
--- a/plugins/windows/src/commands.rs
+++ b/plugins/windows/src/commands.rs
@@ -1,4 +1,4 @@
-use crate::{HyprWindow, KnownPosition, WindowsPluginExt};
+use crate::{FakeWindowBounds, HyprWindow, KnownPosition, OverlayBound, WindowsPluginExt};
#[tauri::command]
#[specta::specta]
@@ -12,43 +12,42 @@ pub async fn window_show(
#[tauri::command]
#[specta::specta]
-pub async fn window_hide(
+pub async fn window_close(
app: tauri::AppHandle,
window: HyprWindow,
) -> Result<(), String> {
- app.window_hide(window).map_err(|e| e.to_string())?;
+ app.window_close(window).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
#[specta::specta]
-pub async fn window_destroy(
+pub async fn window_hide(
app: tauri::AppHandle,
window: HyprWindow,
) -> Result<(), String> {
- app.window_destroy(window).map_err(|e| e.to_string())?;
+ app.window_hide(window).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
#[specta::specta]
-pub async fn window_position(
+pub async fn window_destroy(
app: tauri::AppHandle,
window: HyprWindow,
- pos: KnownPosition,
) -> Result<(), String> {
- app.window_position(window, pos)
- .map_err(|e| e.to_string())?;
+ app.window_destroy(window).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
#[specta::specta]
-pub async fn window_resize_default(
+pub async fn window_position(
app: tauri::AppHandle,
window: HyprWindow,
+ pos: KnownPosition,
) -> Result<(), String> {
- app.window_resize_default(window)
+ app.window_position(window, pos)
.map_err(|e| e.to_string())?;
Ok(())
}
@@ -108,3 +107,76 @@ pub async fn window_emit_navigate(
.map_err(|e| e.to_string())?;
Ok(())
}
+
+async fn update_bounds(
+ window: &tauri::Window,
+ state: &tauri::State<'_, FakeWindowBounds>,
+ name: String,
+ bounds: OverlayBound,
+) -> Result<(), String> {
+ let mut state = state.0.write().await;
+ let map = state.entry(window.label().to_string()).or_default();
+ map.insert(name, bounds);
+ Ok(())
+}
+
+async fn remove_bounds(
+ window: &tauri::Window,
+ state: &tauri::State<'_, FakeWindowBounds>,
+ name: String,
+) -> Result<(), String> {
+ let mut state = state.0.write().await;
+ let Some(map) = state.get_mut(window.label()) else {
+ return Ok(());
+ };
+
+ map.remove(&name);
+
+ if map.is_empty() {
+ state.remove(window.label());
+ }
+
+ Ok(())
+}
+
+#[tauri::command]
+#[specta::specta]
+pub async fn window_set_overlay_bounds(
+ window: tauri::Window,
+ state: tauri::State<'_, FakeWindowBounds>,
+ name: String,
+ bounds: OverlayBound,
+) -> Result<(), String> {
+ update_bounds(&window, &state, name, bounds).await
+}
+
+#[tauri::command]
+#[specta::specta]
+pub async fn window_remove_overlay_bounds(
+ window: tauri::Window,
+ state: tauri::State<'_, FakeWindowBounds>,
+ name: String,
+) -> Result<(), String> {
+ remove_bounds(&window, &state, name).await
+}
+
+#[tauri::command]
+#[specta::specta]
+pub async fn set_fake_window_bounds(
+ window: tauri::Window,
+ name: String,
+ bounds: OverlayBound,
+ state: tauri::State<'_, FakeWindowBounds>,
+) -> Result<(), String> {
+ update_bounds(&window, &state, name, bounds).await
+}
+
+#[tauri::command]
+#[specta::specta]
+pub async fn remove_fake_window(
+ window: tauri::Window,
+ name: String,
+ state: tauri::State<'_, FakeWindowBounds>,
+) -> Result<(), String> {
+ remove_bounds(&window, &state, name).await
+}
diff --git a/plugins/windows/src/ext.rs b/plugins/windows/src/ext.rs
index 2b9c38239..3197b3e0e 100644
--- a/plugins/windows/src/ext.rs
+++ b/plugins/windows/src/ext.rs
@@ -26,6 +26,8 @@ pub enum HyprWindow {
Video(String),
#[serde(rename = "plans")]
Plans,
+ #[serde(rename = "control")]
+ Control,
}
impl std::fmt::Display for HyprWindow {
@@ -39,6 +41,7 @@ impl std::fmt::Display for HyprWindow {
Self::Settings => write!(f, "settings"),
Self::Video(id) => write!(f, "video-{}", id),
Self::Plans => write!(f, "plans"),
+ Self::Control => write!(f, "control"),
}
}
}
@@ -144,6 +147,7 @@ impl HyprWindow {
Self::Settings => "Settings".into(),
Self::Video(_) => "Video".into(),
Self::Plans => "Plans".into(),
+ Self::Control => "Control".into(),
}
}
@@ -152,32 +156,6 @@ impl HyprWindow {
app.get_webview_window(&label)
}
- pub fn get_default_size(&self) -> LogicalSize {
- match self {
- Self::Main => LogicalSize::new(910.0, 600.0),
- Self::Note(_) => LogicalSize::new(480.0, 500.0),
- Self::Human(_) => LogicalSize::new(480.0, 500.0),
- Self::Organization(_) => LogicalSize::new(480.0, 500.0),
- Self::Calendar => LogicalSize::new(640.0, 532.0),
- Self::Settings => LogicalSize::new(800.0, 600.0),
- Self::Video(_) => LogicalSize::new(640.0, 360.0),
- Self::Plans => LogicalSize::new(900.0, 600.0),
- }
- }
-
- pub fn get_min_size(&self) -> LogicalSize {
- match self {
- Self::Main => LogicalSize::new(620.0, 500.0),
- Self::Note(_) => LogicalSize::new(480.0, 360.0),
- Self::Human(_) => LogicalSize::new(480.0, 360.0),
- Self::Organization(_) => LogicalSize::new(480.0, 360.0),
- Self::Calendar => LogicalSize::new(640.0, 532.0),
- Self::Settings => LogicalSize::new(800.0, 600.0),
- Self::Video(_) => LogicalSize::new(640.0, 360.0),
- Self::Plans => LogicalSize::new(900.0, 600.0),
- }
- }
-
pub fn position(
&self,
app: &AppHandle,
@@ -217,6 +195,45 @@ impl HyprWindow {
Ok(())
}
+ fn close(&self, app: &AppHandle) -> Result<(), crate::Error> {
+ match self {
+ HyprWindow::Control => {
+ crate::abort_overlay_join_handle();
+
+ #[cfg(target_os = "macos")]
+ {
+ use tauri_nspanel::ManagerExt;
+ if let Ok(panel) = app.get_webview_panel(&HyprWindow::Control.label()) {
+ app.run_on_main_thread({
+ let panel = panel.clone();
+ move || {
+ panel.set_released_when_closed(true);
+ panel.close();
+ }
+ })
+ .map_err(|e| {
+ tracing::warn!("Failed to run panel close on main thread: {}", e)
+ })
+ .ok();
+ }
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ if let Some(window) = self.get(app) {
+ let _ = window.close();
+ }
+ }
+ }
+ _ => {
+ if let Some(window) = self.get(app) {
+ let _ = window.close();
+ }
+ }
+ }
+
+ Ok(())
+ }
+
fn hide(&self, app: &AppHandle) -> Result<(), crate::Error> {
if let Some(window) = self.get(app) {
window.hide()?;
@@ -240,120 +257,184 @@ impl HyprWindow {
}
pub fn show(&self, app: &AppHandle) -> Result {
- let (window, created) = match self.get(app) {
- Some(window) => (window, false),
- None => {
- let url = match self {
- Self::Main => "/app/new",
- Self::Note(id) => &format!("/app/note/{}", id),
- Self::Human(id) => &format!("/app/human/{}", id),
- Self::Organization(id) => &format!("/app/organization/{}", id),
- Self::Calendar => "/app/calendar",
- Self::Settings => "/app/settings",
- Self::Video(id) => &format!("/video?id={}", id),
- Self::Plans => "/app/plans",
- };
- (self.window_builder(app, url).build()?, true)
- }
- };
-
- if created {
- let default_size = self.get_default_size();
- let min_size = self.get_min_size();
-
- match self {
- Self::Main => {
- window.set_maximizable(true)?;
- window.set_minimizable(true)?;
-
- window.set_size(default_size)?;
- window.set_min_size(Some(min_size))?;
- }
- Self::Note(_) => {
- window.hide()?;
- std::thread::sleep(std::time::Duration::from_millis(100));
-
- window.set_maximizable(false)?;
- window.set_minimizable(false)?;
-
- window.set_size(default_size)?;
- window.set_min_size(Some(min_size))?;
-
- window.center()?;
- }
- Self::Human(_) => {
- window.hide()?;
- std::thread::sleep(std::time::Duration::from_millis(100));
-
- window.set_maximizable(false)?;
- window.set_minimizable(false)?;
-
- window.set_size(default_size)?;
- window.set_min_size(Some(min_size))?;
-
- window.center()?;
- }
- Self::Organization(_) => {
- window.hide()?;
- std::thread::sleep(std::time::Duration::from_millis(100));
+ if self == &Self::Main {
+ use tauri_plugin_analytics::{hypr_analytics::AnalyticsPayload, AnalyticsPluginExt};
+ use tauri_plugin_auth::{AuthPluginExt, StoreKey};
- window.set_maximizable(false)?;
- window.set_minimizable(false)?;
+ let user_id = app
+ .get_from_store(StoreKey::UserId)?
+ .unwrap_or("UNKNOWN".into());
- window.set_size(default_size)?;
- window.set_min_size(Some(min_size))?;
+ let e = AnalyticsPayload::for_user(user_id)
+ .event("show_main_window")
+ .build();
- window.center()?;
+ let app_clone = app.clone();
+ tauri::async_runtime::spawn(async move {
+ if let Err(e) = app_clone.event(e).await {
+ tracing::error!("failed_to_send_analytics: {:?}", e);
}
- Self::Calendar => {
- window.hide()?;
- std::thread::sleep(std::time::Duration::from_millis(100));
-
- window.set_maximizable(false)?;
- window.set_minimizable(false)?;
+ });
+ }
- window.set_size(default_size)?;
- window.set_min_size(Some(min_size))?;
+ if let Some(window) = self.get(app) {
+ window.set_focus()?;
+ window.show()?;
+ return Ok(window);
+ }
- window.center()?;
+ let monitor = app
+ .primary_monitor()?
+ .ok_or_else(|| crate::Error::MonitorNotFound)?;
+
+ let window = match self {
+ Self::Main => {
+ let builder = self
+ .window_builder(app, "/app/new")
+ .maximizable(true)
+ .minimizable(true)
+ .min_inner_size(620.0, 500.0);
+ let window = builder.build()?;
+ window.set_size(LogicalSize::new(910.0, 600.0))?;
+ window
+ }
+ Self::Note(id) => self
+ .window_builder(app, &format!("/app/note/{}", id))
+ .inner_size(480.0, 500.0)
+ .min_inner_size(480.0, 360.0)
+ .center()
+ .build()?,
+ Self::Human(id) => self
+ .window_builder(app, &format!("/app/human/{}", id))
+ .inner_size(480.0, 500.0)
+ .min_inner_size(480.0, 360.0)
+ .center()
+ .build()?,
+ Self::Organization(id) => self
+ .window_builder(app, &format!("/app/organization/{}", id))
+ .inner_size(480.0, 500.0)
+ .min_inner_size(480.0, 360.0)
+ .center()
+ .build()?,
+ Self::Calendar => self
+ .window_builder(app, "/app/calendar")
+ .inner_size(640.0, 532.0)
+ .min_inner_size(640.0, 532.0)
+ .build()?,
+ Self::Settings => self
+ .window_builder(app, "/app/settings")
+ .inner_size(800.0, 600.0)
+ .min_inner_size(800.0, 600.0)
+ .build()?,
+ Self::Video(id) => self
+ .window_builder(app, &format!("/video?id={}", id))
+ .maximizable(false)
+ .minimizable(false)
+ .inner_size(640.0, 360.0)
+ .min_inner_size(640.0, 360.0)
+ .build()?,
+ Self::Plans => self
+ .window_builder(app, "/app/plans")
+ .maximizable(false)
+ .minimizable(false)
+ .inner_size(900.0, 600.0)
+ .min_inner_size(900.0, 600.0)
+ .build()?,
+ Self::Control => {
+ let window_width = (monitor.size().width as f64) / monitor.scale_factor();
+ let window_height = (monitor.size().height as f64) / monitor.scale_factor();
+
+ let mut builder = WebviewWindow::builder(
+ app,
+ self.label(),
+ WebviewUrl::App("/app/control".into()),
+ )
+ .title("")
+ .disable_drag_drop_handler()
+ .maximized(false)
+ .resizable(false)
+ .fullscreen(false)
+ .shadow(false)
+ .always_on_top(true)
+ .visible_on_all_workspaces(true)
+ .accept_first_mouse(true)
+ .content_protected(true)
+ .inner_size(window_width, window_height)
+ .skip_taskbar(true)
+ .position(0.0, 0.0)
+ .transparent(true);
+
+ #[cfg(target_os = "macos")]
+ {
+ builder = builder
+ .title_bar_style(tauri::TitleBarStyle::Overlay)
+ .hidden_title(true);
}
- Self::Settings => {
- window.hide()?;
- std::thread::sleep(std::time::Duration::from_millis(100));
-
- window.set_maximizable(false)?;
- window.set_minimizable(false)?;
-
- window.set_size(default_size)?;
- window.set_min_size(Some(min_size))?;
- window.center()?;
+ #[cfg(not(target_os = "macos"))]
+ {
+ builder = builder.decorations(false);
}
- Self::Video(_) => {
- window.hide()?;
- std::thread::sleep(std::time::Duration::from_millis(100));
- window.set_resizable(false)?;
- window.set_maximizable(false)?;
- window.set_minimizable(false)?;
-
- window.set_size(default_size)?;
- window.set_min_size(Some(min_size))?;
+ let window = builder.build()?;
+
+ #[cfg(target_os = "macos")]
+ {
+ #[allow(deprecated, unexpected_cfgs)]
+ app.run_on_main_thread({
+ let window = window.clone();
+ move || {
+ use objc2::runtime::AnyObject;
+ use objc2::msg_send;
+
+ // Hide traffic lights using cocoa APIs
+ if let Ok(ns_window) = window.ns_window() {
+ unsafe {
+ let ns_window = ns_window as *mut AnyObject;
+ let ns_window = &*ns_window;
+
+ // NSWindow button type constants
+ const NS_WINDOW_CLOSE_BUTTON: u64 = 0;
+ const NS_WINDOW_MINIATURIZE_BUTTON: u64 = 1;
+ const NS_WINDOW_ZOOM_BUTTON: u64 = 2;
+
+ // Get and hide the standard window buttons
+ let close_button: *mut AnyObject = msg_send![ns_window, standardWindowButton: NS_WINDOW_CLOSE_BUTTON];
+ let miniaturize_button: *mut AnyObject = msg_send![ns_window, standardWindowButton: NS_WINDOW_MINIATURIZE_BUTTON];
+ let zoom_button: *mut AnyObject = msg_send![ns_window, standardWindowButton: NS_WINDOW_ZOOM_BUTTON];
+
+ if !close_button.is_null() {
+ let _: () = msg_send![close_button, setHidden: true];
+ }
+ if !miniaturize_button.is_null() {
+ let _: () = msg_send![miniaturize_button, setHidden: true];
+ }
+ if !zoom_button.is_null() {
+ let _: () = msg_send![zoom_button, setHidden: true];
+ }
+
+ // Make title bar transparent instead of changing style mask
+ let _: () = msg_send![ns_window, setTitlebarAppearsTransparent: true];
+ let _: () = msg_send![ns_window, setMovableByWindowBackground: true];
+ }
+ }
+ }
+ }).map_err(|e| tracing::warn!("Failed to run window setup on main thread: {}", e)).ok();
}
- Self::Plans => {
- window.hide()?;
- std::thread::sleep(std::time::Duration::from_millis(100));
- window.set_maximizable(false)?;
- window.set_minimizable(false)?;
+ let join_handle = crate::spawn_overlay_listener(app.clone(), window.clone());
+ crate::set_overlay_join_handle(join_handle);
- window.set_size(default_size)?;
- window.set_min_size(Some(min_size))?;
+ // Cancel the overlay listener when the window is closed
+ window.on_window_event(move |event| {
+ if let tauri::WindowEvent::CloseRequested { .. } = event {
+ crate::abort_overlay_join_handle();
+ }
+ });
- window.center()?;
- }
- };
- }
+ window
+ }
+ };
window.set_focus()?;
window.show()?;
@@ -374,18 +455,23 @@ impl HyprWindow {
) -> WebviewWindowBuilder<'a, tauri::Wry, AppHandle> {
let mut builder = WebviewWindow::builder(app, self.label(), WebviewUrl::App(url.into()))
.title(self.title())
- .decorations(true)
.disable_drag_drop_handler();
#[cfg(target_os = "macos")]
{
builder = builder
+ .decorations(true)
.hidden_title(true)
.theme(Some(tauri::Theme::Light))
.traffic_light_position(tauri::LogicalPosition::new(12.0, 20.0))
.title_bar_style(tauri::TitleBarStyle::Overlay);
}
+ #[cfg(target_os = "windows")]
+ {
+ builder = builder.decorations(false);
+ }
+
builder
}
}
@@ -394,10 +480,10 @@ pub trait WindowsPluginExt {
fn handle_main_window_visibility(&self, visible: bool) -> Result<(), crate::Error>;
fn window_show(&self, window: HyprWindow) -> Result;
+ fn window_close(&self, window: HyprWindow) -> Result<(), crate::Error>;
fn window_hide(&self, window: HyprWindow) -> Result<(), crate::Error>;
fn window_destroy(&self, window: HyprWindow) -> Result<(), crate::Error>;
fn window_position(&self, window: HyprWindow, pos: KnownPosition) -> Result<(), crate::Error>;
- fn window_resize_default(&self, window: HyprWindow) -> Result<(), crate::Error>;
fn window_is_visible(&self, window: HyprWindow) -> Result;
fn window_get_floating(&self, window: HyprWindow) -> Result;
@@ -474,6 +560,10 @@ impl WindowsPluginExt for AppHandle {
window.show(self)
}
+ fn window_close(&self, window: HyprWindow) -> Result<(), crate::Error> {
+ window.close(self)
+ }
+
fn window_hide(&self, window: HyprWindow) -> Result<(), crate::Error> {
window.hide(self)
}
@@ -486,15 +576,6 @@ impl WindowsPluginExt for AppHandle {
window.position(self, pos)
}
- fn window_resize_default(&self, window: HyprWindow) -> Result<(), crate::Error> {
- if let Some(w) = window.get(self) {
- let default_size = window.get_default_size();
- w.set_size(default_size)?;
- }
-
- Ok(())
- }
-
fn window_is_visible(&self, window: HyprWindow) -> Result {
window.is_visible(self)
}
diff --git a/plugins/windows/src/lib.rs b/plugins/windows/src/lib.rs
index 8f1f65a2a..d10bf325a 100644
--- a/plugins/windows/src/lib.rs
+++ b/plugins/windows/src/lib.rs
@@ -2,18 +2,43 @@ mod commands;
mod errors;
mod events;
mod ext;
+mod overlay;
pub use errors::*;
pub use events::*;
pub use ext::*;
+use overlay::*;
+pub use overlay::{FakeWindowBounds, OverlayBound};
const PLUGIN_NAME: &str = "windows";
+use once_cell::sync::Lazy;
+use std::sync::Mutex;
use tauri::Manager;
use uuid::Uuid;
pub type ManagedState = std::sync::Mutex;
+static OVERLAY_JOIN_HANDLE: Lazy>>> =
+ Lazy::new(|| Mutex::new(None));
+
+pub fn set_overlay_join_handle(handle: tokio::task::JoinHandle<()>) {
+ if let Ok(mut guard) = OVERLAY_JOIN_HANDLE.lock() {
+ if let Some(old_handle) = guard.take() {
+ old_handle.abort();
+ }
+ *guard = Some(handle);
+ }
+}
+
+pub fn abort_overlay_join_handle() {
+ if let Ok(mut guard) = OVERLAY_JOIN_HANDLE.lock() {
+ if let Some(handle) = guard.take() {
+ handle.abort();
+ }
+ }
+}
+
pub struct WindowState {
id: String,
floating: bool,
@@ -45,15 +70,19 @@ fn make_specta_builder() -> tauri_specta::Builder {
])
.commands(tauri_specta::collect_commands![
commands::window_show,
+ commands::window_close,
commands::window_hide,
commands::window_destroy,
commands::window_position,
- commands::window_resize_default,
commands::window_get_floating,
commands::window_set_floating,
commands::window_navigate,
commands::window_emit_navigate,
commands::window_is_visible,
+ commands::window_set_overlay_bounds,
+ commands::window_remove_overlay_bounds,
+ commands::set_fake_window_bounds,
+ commands::remove_fake_window,
])
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
}
@@ -65,8 +94,17 @@ pub fn init() -> tauri::plugin::TauriPlugin {
.invoke_handler(specta_builder.invoke_handler())
.setup(move |app, _api| {
specta_builder.mount_events(app);
- let state = ManagedState::default();
- app.manage(state);
+
+ {
+ let state = ManagedState::default();
+ app.manage(state);
+ }
+
+ {
+ let fake_bounds_state = FakeWindowBounds::default();
+ app.manage(fake_bounds_state);
+ }
+
Ok(())
})
.build()
diff --git a/plugins/windows/src/overlay.rs b/plugins/windows/src/overlay.rs
new file mode 100644
index 000000000..d60a96365
--- /dev/null
+++ b/plugins/windows/src/overlay.rs
@@ -0,0 +1,106 @@
+use std::{collections::HashMap, sync::Arc, time::Duration};
+use tauri::{AppHandle, Manager, WebviewWindow};
+use tokio::{sync::RwLock, time::sleep};
+
+#[derive(Debug, Default, serde::Serialize, serde::Deserialize, specta::Type, Clone, Copy)]
+pub struct OverlayBound {
+ pub x: f64,
+ pub y: f64,
+ pub width: f64,
+ pub height: f64,
+}
+
+pub struct FakeWindowBounds(pub Arc>>>);
+
+impl Default for FakeWindowBounds {
+ fn default() -> Self {
+ Self(Arc::new(RwLock::new(HashMap::new())))
+ }
+}
+
+pub fn spawn_overlay_listener(
+ app: AppHandle,
+ window: WebviewWindow,
+) -> tokio::task::JoinHandle<()> {
+ window.set_ignore_cursor_events(true).ok();
+
+ tokio::spawn(async move {
+ let state = app.state::();
+ let mut last_ignore_state = true;
+ let mut last_focus_state = false;
+
+ loop {
+ sleep(Duration::from_millis(1000 / 10)).await;
+
+ let map = state.0.read().await;
+
+ let Some(windows) = map.get(window.label()) else {
+ if !last_ignore_state {
+ window.set_ignore_cursor_events(true).ok();
+ last_ignore_state = true;
+ }
+ continue;
+ };
+
+ if windows.is_empty() {
+ if !last_ignore_state {
+ window.set_ignore_cursor_events(true).ok();
+ last_ignore_state = true;
+ }
+ continue;
+ };
+
+ let (Ok(window_position), Ok(mouse_position), Ok(scale_factor)) = (
+ window.outer_position(),
+ window.cursor_position(),
+ window.scale_factor(),
+ ) else {
+ if !last_ignore_state {
+ if let Err(e) = window.set_ignore_cursor_events(true) {
+ tracing::warn!("Failed to set ignore cursor events: {}", e);
+ }
+ last_ignore_state = true;
+ }
+ continue;
+ };
+
+ let mut ignore = true;
+
+ for (_name, bounds) in windows.iter() {
+ let x_min = (window_position.x as f64) + bounds.x * scale_factor;
+ let x_max = (window_position.x as f64) + (bounds.x + bounds.width) * scale_factor;
+ let y_min = (window_position.y as f64) + bounds.y * scale_factor;
+ let y_max = (window_position.y as f64) + (bounds.y + bounds.height) * scale_factor;
+
+ if mouse_position.x >= x_min
+ && mouse_position.x <= x_max
+ && mouse_position.y >= y_min
+ && mouse_position.y <= y_max
+ {
+ ignore = false;
+ break;
+ }
+ }
+
+ if ignore != last_ignore_state {
+ if let Err(e) = window.set_ignore_cursor_events(ignore) {
+ tracing::warn!("Failed to set ignore cursor events: {}", e);
+ }
+ last_ignore_state = ignore;
+ }
+
+ let focused = window.is_focused().unwrap_or(false);
+ if !ignore && !focused {
+ // Only try to set focus if we haven't already done so for this hover state
+ if !last_focus_state {
+ if window.set_focus().is_ok() {
+ last_focus_state = true;
+ }
+ }
+ } else if ignore || focused {
+ // Reset focus state when cursor leaves or window gains focus naturally
+ last_focus_state = false;
+ }
+ }
+ })
+}