diff --git a/apps/desktop/index.html b/apps/desktop/index.html index bd431afc4a..9519f07feb 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 caa09592e7..3480e18f0b 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 @@ -11,6 +11,7 @@ import { VolumeOffIcon, } from "lucide-react"; import { useEffect, useState } from "react"; +import { emit, listen } from "@tauri-apps/api/event"; import SoundIndicator from "@/components/sound-indicator"; import { useHypr } from "@/contexts"; @@ -227,7 +228,8 @@ export function WhenActive() { useEffect(() => { if (showConsent) { - listenerCommands.setSpeakerMuted(true).then(() => { + listenerCommands.setSpeakerMuted(true).then(async () => { + await emit("audio-speaker-state-changed", { muted: true }); audioControls.refetchSpeakerMuted(); }); } @@ -237,9 +239,12 @@ export function WhenActive() { mutationFn: async (recordEveryone: boolean) => { if (recordEveryone) { await listenerCommands.setSpeakerMuted(false); + await emit("audio-speaker-state-changed", { muted: false }); } else { await listenerCommands.setSpeakerMuted(true); await listenerCommands.setMicMuted(false); + await emit("audio-speaker-state-changed", { muted: true }); + await emit("audio-mic-state-changed", { muted: false }); } setHasShownConsent(true); }, @@ -438,13 +443,43 @@ function useAudioControls() { queryFn: () => listenerCommands.getSpeakerMuted(), }); + // Listen for audio state changes from other windows + useEffect(() => { + const unsubscribeMicState = listen<{ muted: boolean }>("audio-mic-state-changed", ({ payload }) => { + console.log(`[Main Window] Received mic state change:`, payload); + refetchMicMuted(); + }); + + const unsubscribeSpeakerState = listen<{ muted: boolean }>("audio-speaker-state-changed", ({ payload }) => { + console.log(`[Main Window] Received speaker state change:`, payload); + refetchSpeakerMuted(); + }); + + return () => { + unsubscribeMicState.then(unlisten => unlisten()); + unsubscribeSpeakerState.then(unlisten => unlisten()); + }; + }, [refetchMicMuted, refetchSpeakerMuted]); + const toggleMicMuted = useMutation({ - mutationFn: () => listenerCommands.setMicMuted(!isMicMuted), + mutationFn: async () => { + const newMuted = !isMicMuted; + await listenerCommands.setMicMuted(newMuted); + // Emit event to synchronize with other windows + await emit("audio-mic-state-changed", { muted: newMuted }); + return newMuted; + }, onSuccess: () => refetchMicMuted(), }); const toggleSpeakerMuted = useMutation({ - mutationFn: () => listenerCommands.setSpeakerMuted(!isSpeakerMuted), + mutationFn: async () => { + const newMuted = !isSpeakerMuted; + await listenerCommands.setSpeakerMuted(newMuted); + // Emit event to synchronize with other windows + await emit("audio-speaker-state-changed", { muted: newMuted }); + return newMuted; + }, onSuccess: () => refetchSpeakerMuted(), }); diff --git a/apps/desktop/src/routes/__root.tsx b/apps/desktop/src/routes/__root.tsx index f4960ca490..4a807ab07f 100644 --- a/apps/desktop/src/routes/__root.tsx +++ b/apps/desktop/src/routes/__root.tsx @@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query"; import { CatchNotFound, createRootRouteWithContext, Outlet, useNavigate } from "@tanstack/react-router"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; +import { listen } from "@tauri-apps/api/event"; import { lazy, Suspense, useEffect } from "react"; import { CatchNotFoundFallback, ErrorComponent, NotFoundComponent } from "@/components/control"; @@ -66,6 +67,19 @@ function Component() { scan({ enabled: false }); }, []); + // 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 index a1f86900d0..7979c91d4d 100644 --- a/apps/desktop/src/routes/app.control.tsx +++ b/apps/desktop/src/routes/app.control.tsx @@ -1,16 +1,17 @@ import { createFileRoute } from "@tanstack/react-router"; -import { getCurrentWindow } from "@tauri-apps/api/window"; -import { Camera, Circle, Grip, Settings, Square } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { Camera, Circle, Grip, Settings, Square, Mic, MicOff, Volume2, VolumeX } from "lucide-react"; +import React, { useEffect, useRef, useState } from "react"; +import { emit, listen } from "@tauri-apps/api/event"; import { commands as windowsCommands } from "@hypr/plugin-windows"; +import { commands as listenerCommands, events as listenerEvents } from "@hypr/plugin-listener"; export const Route = createFileRoute("/app/control")({ component: Component, }); function Component() { - const currentWindowLabel = useMemo(() => getCurrentWindow().label, []); + emit("debug", "Control component mounted"); const [position, setPosition] = useState(() => { const windowWidth = window.innerWidth; @@ -20,31 +21,189 @@ function Component() { const [isDragging, setIsDragging] = useState(false); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); - const [isRecording, setIsRecording] = useState(false); + + // 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 [showSettings, setShowSettings] = useState(false); + + // Settings toggles state + const [autoStartRecording, setAutoStartRecording] = useState(() => { + return localStorage.getItem('autoStartRecording') === 'true'; + }); + const [showAudioLevels, setShowAudioLevels] = useState(() => { + return localStorage.getItem('showAudioLevels') !== 'false'; // default true + }); + const [alwaysOnTop, setAlwaysOnTop] = useState(() => { + return localStorage.getItem('alwaysOnTop') !== 'false'; // default true + }); + + 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(); + + // Listen for session events + const unsubscribeSession = listenerEvents.sessionEvent.listen(({ payload }) => { + console.log(`[Control Bar] Session event:`, payload); + + if (payload.type === "inactive" || payload.type === "running_active" || payload.type === "running_paused") { + setRecordingStatus(payload.type); + setRecordingLoading(false); + } + }); + + // Listen for audio state changes from other windows + const unsubscribeMicState = listen<{ muted: boolean }>("audio-mic-state-changed", ({ payload }) => { + console.log(`[Control Bar] Received mic state change:`, payload); + setMicMuted(payload.muted); + }); + + const unsubscribeSpeakerState = listen<{ muted: boolean }>("audio-speaker-state-changed", ({ payload }) => { + console.log(`[Control Bar] Received speaker state change:`, payload); + setSpeakerMuted(payload.muted); + }); + + return () => { + unsubscribeSession.then(unlisten => unlisten()); + unsubscribeMicState.then(unlisten => unlisten()); + unsubscribeSpeakerState.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); + const toolbarRef = useRef(null); + const settingsPopupRef = useRef(null); const updateOverlayBounds = async () => { - if (controlRef.current) { - const rect = controlRef.current.getBoundingClientRect(); - await windowsCommands.windowSetOverlayBounds(currentWindowLabel, { - x: rect.left, - y: rect.top, - width: rect.width, - height: rect.height, - }); + emit("debug", "updateOverlayBounds called"); + emit("debug", `toolbarRef.current: ${toolbarRef.current ? 'exists' : 'null'}`); + emit("debug", `showSettings: ${showSettings}`); + emit("debug", `settingsPopupRef.current: ${settingsPopupRef.current ? 'exists' : 'null'}`); + + if (toolbarRef.current) { + const toolbarRect = toolbarRef.current.getBoundingClientRect(); + + let bounds = { + x: position.x, + y: position.y, + width: toolbarRect.width, + height: toolbarRect.height, + }; + + // If settings popup is open, calculate combined bounds + if (showSettings) { + // Calculate popup position manually based on how it's positioned in the component + const isNearTop = position.y < 250; + const popupTop = isNearTop ? position.y + 60 : position.y - 200; + const popupLeft = position.x; + const popupWidth = 256; // w-64 in Tailwind = 256px + const popupHeight = 200; // Approximate height of the settings popup + + // Calculate the combined bounding box + const minX = Math.min(position.x, popupLeft); + const minY = Math.min(position.y, popupTop); + const maxX = Math.max(position.x + toolbarRect.width, popupLeft + popupWidth); + const maxY = Math.max(position.y + toolbarRect.height, popupTop + popupHeight); + + bounds = { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; + + emit("debug", `Popup position: ${JSON.stringify({x: popupLeft, y: popupTop, width: popupWidth, height: popupHeight})}`); + emit("debug", `Combined bounds: ${JSON.stringify(bounds)}`); + + // Double-check with actual rect if ref is available + if (settingsPopupRef.current) { + const popupRect = settingsPopupRef.current.getBoundingClientRect(); + emit("debug", `Actual popup rect: ${JSON.stringify({x: popupRect.left, y: popupRect.top, width: popupRect.width, height: popupRect.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); + emit("debug", "setFakeWindowBounds completed successfully"); + } catch (error) { + emit("debug", `setFakeWindowBounds failed: ${error}`); + } + } else { + emit("debug", "toolbarRef.current is null, skipping bounds update"); } }; + // Add click handler to test if the fake window bounds are working + const handleToolbarClick = (e: React.MouseEvent) => { + emit("debug", `Toolbar clicked at: ${JSON.stringify({ x: e.clientX, y: e.clientY })}`); + // 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 (isDragging) { - setPosition({ - x: e.clientX - dragOffset.x, - y: e.clientY - dragOffset.y, - }); + // 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 - dragOffset.x)); + const clampedY = Math.max(0, Math.min(window.innerHeight - toolbarHeight, e.clientY - dragOffset.y)); + + const newPosition = { + x: clampedX, + y: clampedY, + }; + setPosition(newPosition); + // Update bounds immediately during drag for smooth interaction + setTimeout(updateOverlayBounds, 0); } }; @@ -60,14 +219,42 @@ function Component() { return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); - windowsCommands.windowRemoveOverlayBounds(currentWindowLabel); + windowsCommands.removeFakeWindow("control"); }; }, [isDragging, dragOffset]); useEffect(() => { + // Update bounds whenever position changes updateOverlayBounds(); }, [position]); + // Separate effect for settings popup to ensure it's rendered + useEffect(() => { + if (showSettings) { + // Wait for popup to be rendered and ref to be available + const timer = setTimeout(() => { + updateOverlayBounds(); + }, 50); + return () => clearTimeout(timer); + } else { + // Immediately update when popup closes + updateOverlayBounds(); + } + }, [showSettings]); + + // Also update bounds after initial render + useEffect(() => { + emit("debug", "Initial useEffect running"); + emit("debug", `windowsCommands available: ${!!windowsCommands}`); + emit("debug", `windowsCommands.setFakeWindowBounds available: ${!!windowsCommands.setFakeWindowBounds}`); + + const timer = setTimeout(() => { + emit("debug", "Timer fired, calling updateOverlayBounds"); + updateOverlayBounds(); + }, 100); + return () => clearTimeout(timer); + }, []); + const handleMouseDown = (e: React.MouseEvent) => { setIsDragging(true); setDragOffset({ @@ -77,25 +264,109 @@ function Component() { }; const captureScreenshot = () => { - console.log("Capture screenshot"); + emit("debug", "Capture screenshot"); setTimeout(updateOverlayBounds, 0); }; - const toggleRecording = () => { - setIsRecording(!isRecording); - console.log(isRecording ? "Stop recording" : "Start recording"); + const toggleRecording = async () => { + try { + setRecordingLoading(true); + + if (isRecording) { + if (isRecordingActive) { + await listenerCommands.stopSession(); + console.log("[Control Bar] Stopped recording"); + } else if (isRecordingPaused) { + await listenerCommands.resumeSession(); + console.log("[Control Bar] Resumed recording"); + } + } else { + // Create a new session and start recording + const newSessionId = `control-session-${Date.now()}`; + await listenerCommands.startSession(newSessionId); + console.log(`[Control Bar] Started recording with session: ${newSessionId}`); + } + } catch (error) { + console.error("[Control Bar] Recording error:", error); + } finally { + setRecordingLoading(false); + } setTimeout(updateOverlayBounds, 0); }; + + const pauseRecording = async () => { + try { + setRecordingLoading(true); + if (isRecordingActive) { + await listenerCommands.pauseSession(); + console.log("[Control Bar] Paused recording"); + } + } catch (error) { + console.error("[Control Bar] Pause error:", error); + } finally { + setRecordingLoading(false); + } + setTimeout(updateOverlayBounds, 0); + }; + + 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); + } + }; const openSettings = () => { - console.log("Open settings"); - setTimeout(updateOverlayBounds, 0); + setShowSettings(!showSettings); + console.log(`[Control Bar] ${showSettings ? "Closed" : "Opened"} settings`); }; + const toggleAutoStart = () => { + const newValue = !autoStartRecording; + setAutoStartRecording(newValue); + localStorage.setItem('autoStartRecording', newValue.toString()); + }; + + const toggleAudioLevels = () => { + const newValue = !showAudioLevels; + setShowAudioLevels(newValue); + localStorage.setItem('showAudioLevels', newValue.toString()); + }; + + const toggleAlwaysOnTop = () => { + const newValue = !alwaysOnTop; + setAlwaysOnTop(newValue); + localStorage.setItem('alwaysOnTop', newValue.toString()); + }; + + return (
+ - {isRecording ? : } + {recordingLoading ? ( +
+ ) : isRecordingActive ? ( + + ) : ( + + )} + + {/* Pause Button - only show when actively recording */} + {isRecordingActive && ( + + {recordingLoading ? ( +
+ ) : ( +
+
+
+
+ )} + + )} + + {/* Audio Controls - show when recording */} + {isRecording && ( + <> + + {micMuted ? : } + + + + {speakerMuted ? : } + + + )} + +
+ +
+ + {/* Settings Popup */} + setShowSettings(false)} + position={position} + autoStartRecording={autoStartRecording} + showAudioLevels={showAudioLevels} + alwaysOnTop={alwaysOnTop} + toggleAutoStart={toggleAutoStart} + toggleAudioLevels={toggleAudioLevels} + toggleAlwaysOnTop={toggleAlwaysOnTop} + />
); } -function IconButton({ onClick, children, className = "", tooltip = "" }: { +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 ( ); } + +// Settings popup component +const SettingsPopup = React.forwardRef void; + position: { x: number; y: number }; + autoStartRecording: boolean; + showAudioLevels: boolean; + alwaysOnTop: boolean; + toggleAutoStart: () => void; + toggleAudioLevels: () => void; + toggleAlwaysOnTop: () => void; +}>(({ + isOpen, + onClose, + position, + autoStartRecording, + showAudioLevels, + alwaysOnTop, + toggleAutoStart, + toggleAudioLevels, + toggleAlwaysOnTop +}, ref) => { + if (!isOpen) return null; + + // Smart positioning - open downwards if close to top, upwards otherwise + const isNearTop = position.y < 250; // Within 250px of top + const popupTop = isNearTop ? position.y + 60 : position.y - 200; // 60px below control bar or 200px above + + return ( +
+
+
+

Recording Settings

+ +
+ +
+
+ Auto-start recording + +
+ +
+ Show audio levels + +
+ +
+ Always on top + +
+ +
+ +
+
+
+
+ ); +}); + +SettingsPopup.displayName = "SettingsPopup"; diff --git a/packages/utils/src/stores/ongoing-session.ts b/packages/utils/src/stores/ongoing-session.ts index 49646ff1ea..e499a1cddd 100644 --- a/packages/utils/src/stores/ongoing-session.ts +++ b/packages/utils/src/stores/ongoing-session.ts @@ -16,6 +16,7 @@ type State = { type Actions = { get: () => State & Actions; + cleanup: () => void; cancelEnhance: () => void; setEnhanceController: (controller: AbortController | null) => void; setHasShownConsent: (hasShown: boolean) => void; @@ -37,10 +38,58 @@ const initialState: State = { export type OngoingSessionStore = ReturnType; export const createOngoingSessionStore = (sessionsStore: ReturnType) => { - return createStore((set, get) => ({ - ...initialState, - get: () => get(), - cancelEnhance: () => { + return createStore((set, get) => { + // Set up global session event listener + listenerEvents.sessionEvent.listen(({ payload }) => { + if (payload.type === "audioAmplitude") { + set((state) => + mutate(state, (draft) => { + draft.amplitude = { + mic: payload.mic, + speaker: payload.speaker, + }; + }) + ); + } else if (payload.type === "running_active") { + set((state) => + 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; + }) + ); + } + }).then((unlisten) => { + set((state) => + mutate(state, (draft) => { + draft.sessionEventUnlisten = unlisten; + }) + ); + }); + + return { + ...initialState, + get: () => get(), + cleanup: () => { + const { sessionEventUnlisten } = get(); + if (sessionEventUnlisten) { + sessionEventUnlisten(); + } + }, + cancelEnhance: () => { const { enhanceController } = get(); if (enhanceController) { enhanceController.abort(); @@ -71,25 +120,6 @@ export const createOngoingSessionStore = (sessionsStore: ReturnType { - if (payload.type === "audioAmplitude") { - set((state) => - mutate(state, (draft) => { - draft.amplitude = { - mic: payload.mic, - speaker: payload.speaker, - }; - }) - ); - } - }).then((unlisten) => { - set((state) => - mutate(state, (draft) => { - draft.sessionEventUnlisten = unlisten; - }) - ); - }); - listenerCommands.startSession(sessionId).then(() => { set({ status: "running_active", loading: false }); }).catch((error) => { @@ -134,5 +164,6 @@ export const createOngoingSessionStore = (sessionsStore: ReturnType { + async windowShow(window: HyprWindow): Promise { return await TAURI_INVOKE("plugin:windows|window_show", { window }); -}, -async windowClose(window: HyprWindow) : Promise { + }, + async windowClose(window: HyprWindow): Promise { return await TAURI_INVOKE("plugin:windows|window_close", { window }); -}, -async windowHide(window: HyprWindow) : Promise { + }, + async windowHide(window: HyprWindow): Promise { return await TAURI_INVOKE("plugin:windows|window_hide", { window }); -}, -async windowDestroy(window: HyprWindow) : Promise { + }, + async windowDestroy(window: HyprWindow): Promise { return await TAURI_INVOKE("plugin:windows|window_destroy", { window }); -}, -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 { + }, + 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 }); -}, -async windowSetFloating(window: HyprWindow, v: boolean) : Promise { - return await TAURI_INVOKE("plugin:windows|window_set_floating", { window, v }); -}, -async windowNavigate(window: HyprWindow, path: string) : Promise { - return await TAURI_INVOKE("plugin:windows|window_navigate", { window, path }); -}, -async windowEmitNavigate(window: HyprWindow, path: string) : Promise { - return await TAURI_INVOKE("plugin:windows|window_emit_navigate", { window, path }); -}, -async windowIsVisible(window: HyprWindow) : Promise { + }, + async windowSetFloating(window: HyprWindow, v: boolean): Promise { + return await TAURI_INVOKE("plugin:windows|window_set_floating", { + window, + v, + }); + }, + async windowNavigate(window: HyprWindow, path: string): Promise { + return await TAURI_INVOKE("plugin:windows|window_navigate", { + window, + path, + }); + }, + async windowEmitNavigate(window: HyprWindow, path: string): Promise { + return await TAURI_INVOKE("plugin:windows|window_emit_navigate", { + window, + path, + }); + }, + 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 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 }); + }, +}; /** user-defined events **/ - export const events = __makeEvents__<{ -mainWindowState: MainWindowState, -navigate: Navigate, -windowDestroyed: WindowDestroyed + mainWindowState: MainWindowState; + navigate: Navigate; + windowDestroyed: WindowDestroyed; }>({ -mainWindowState: "plugin:windows:main-window-state", -navigate: "plugin:windows:navigate", -windowDestroyed: "plugin:windows:window-destroyed" -}) + mainWindowState: "plugin:windows:main-window-state", + navigate: "plugin:windows:navigate", + windowDestroyed: "plugin:windows:window-destroyed", +}); /** user-defined constants **/ - - /** 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" } | { 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 } +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 **/ import { - invoke as TAURI_INVOKE, - Channel as TAURI_CHANNEL, + invoke as TAURI_INVOKE, + Channel as TAURI_CHANNEL, } from "@tauri-apps/api/core"; import * as TAURI_API_EVENT from "@tauri-apps/api/event"; import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; type __EventObj__ = { - listen: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - once: ( - cb: TAURI_API_EVENT.EventCallback, - ) => ReturnType>; - emit: null extends T - ? (payload?: T) => ReturnType - : (payload: T) => ReturnType; + listen: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + once: ( + cb: TAURI_API_EVENT.EventCallback, + ) => ReturnType>; + emit: null extends T + ? (payload?: T) => ReturnType + : (payload: T) => ReturnType; }; export type Result = - | { status: "ok"; data: T } - | { status: "error"; error: E }; + | { status: "ok"; data: T } + | { status: "error"; error: E }; function __makeEvents__>( - mappings: Record, + mappings: Record, ) { - return new Proxy( - {} as unknown as { - [K in keyof T]: __EventObj__ & { - (handle: __WebviewWindow__): __EventObj__; - }; - }, - { - get: (_, event) => { - const name = mappings[event as keyof T]; - - return new Proxy((() => {}) as any, { - apply: (_, __, [window]: [__WebviewWindow__]) => ({ - listen: (arg: any) => window.listen(name, arg), - once: (arg: any) => window.once(name, arg), - emit: (arg: any) => window.emit(name, arg), - }), - get: (_, command: keyof __EventObj__) => { - switch (command) { - case "listen": - return (arg: any) => TAURI_API_EVENT.listen(name, arg); - case "once": - return (arg: any) => TAURI_API_EVENT.once(name, arg); - case "emit": - return (arg: any) => TAURI_API_EVENT.emit(name, arg); - } - }, - }); - }, - }, - ); + return new Proxy( + {} as unknown as { + [K in keyof T]: __EventObj__ & { + (handle: __WebviewWindow__): __EventObj__; + }; + }, + { + get: (_, event) => { + const name = mappings[event as keyof T]; + + return new Proxy((() => {}) as any, { + apply: (_, __, [window]: [__WebviewWindow__]) => ({ + listen: (arg: any) => window.listen(name, arg), + once: (arg: any) => window.once(name, arg), + emit: (arg: any) => window.emit(name, arg), + }), + get: (_, command: keyof __EventObj__) => { + switch (command) { + case "listen": + return (arg: any) => TAURI_API_EVENT.listen(name, arg); + case "once": + return (arg: any) => TAURI_API_EVENT.once(name, arg); + case "emit": + return (arg: any) => TAURI_API_EVENT.emit(name, arg); + } + }, + }); + }, + }, + ); } 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 0000000000..57e96c6898 --- /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 0000000000..cc7a685aa1 --- /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/reference.md b/plugins/windows/permissions/autogenerated/reference.md index 6c3e22c2e4..88c7f098f1 100644 --- a/plugins/windows/permissions/autogenerated/reference.md +++ b/plugins/windows/permissions/autogenerated/reference.md @@ -14,6 +14,10 @@ Default permissions for the plugin - `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,58 @@ 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. + + + + diff --git a/plugins/windows/permissions/default.toml b/plugins/windows/permissions/default.toml index d72b93cd53..fed8586181 100644 --- a/plugins/windows/permissions/default.toml +++ b/plugins/windows/permissions/default.toml @@ -11,4 +11,8 @@ permissions = [ "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 763425f6cd..c8e1f37278 100644 --- a/plugins/windows/permissions/schemas/schema.json +++ b/plugins/windows/permissions/schemas/schema.json @@ -294,6 +294,30 @@ "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", @@ -451,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-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`\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-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`\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 679e8df34d..373bf1b0a9 100644 --- a/plugins/windows/src/commands.rs +++ b/plugins/windows/src/commands.rs @@ -1,4 +1,5 @@ -use crate::{HyprWindow, KnownPosition, WindowsPluginExt}; +use crate::{HyprWindow, KnownPosition, WindowsPluginExt, FakeWindowBounds, OverlayBound}; +use std::collections::HashMap; #[tauri::command] #[specta::specta] @@ -119,29 +120,28 @@ pub async fn window_emit_navigate( Ok(()) } -#[tauri::command] -#[specta::specta] -pub async fn window_set_overlay_bounds( - window: tauri::Window, - state: tauri::State<'_, crate::OverlayState>, +async fn update_bounds( + window: &tauri::Window, + state: &tauri::State<'_, FakeWindowBounds>, name: String, - bounds: crate::OverlayBound, + bounds: OverlayBound, ) -> Result<(), String> { - let mut state = state.bounds.write().await; + #[cfg(debug_assertions)] + println!("Setting bounds for {}: {:?}", name, bounds); + let mut state = state.0.write().await; let map = state.entry(window.label().to_string()).or_default(); map.insert(name, bounds); - + #[cfg(debug_assertions)] + println!("Total bounds for window {}: {}", window.label(), map.len()); Ok(()) } -#[tauri::command] -#[specta::specta] -pub async fn window_remove_overlay_bounds( - window: tauri::Window, - state: tauri::State<'_, crate::OverlayState>, +async fn remove_bounds( + window: &tauri::Window, + state: &tauri::State<'_, FakeWindowBounds>, name: String, ) -> Result<(), String> { - let mut state = state.bounds.write().await; + let mut state = state.0.write().await; let Some(map) = state.get_mut(window.label()) else { return Ok(()); }; @@ -154,3 +154,45 @@ pub async fn window_remove_overlay_bounds( 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 7756553d01..cc2b18c2c9 100644 --- a/plugins/windows/src/ext.rs +++ b/plugins/windows/src/ext.rs @@ -353,8 +353,9 @@ impl HyprWindow { .min_inner_size(900.0, 600.0) .build()?, Self::Control => { - let window = self - .window_builder(app, "/app/control") + let mut builder = WebviewWindow::builder(app, self.label(), WebviewUrl::App("/app/control".into())) + .title("") + .disable_drag_drop_handler() .maximized(false) .resizable(false) .fullscreen(false) @@ -369,32 +370,58 @@ impl HyprWindow { ) .skip_taskbar(true) .position(0.0, 0.0) - .transparent(true) - .build()?; + .transparent(true); + + #[cfg(target_os = "macos")] + { + builder = builder + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true); + } + + #[cfg(not(target_os = "macos"))] + { + builder = builder.decorations(false); + } + + let window = builder.build()?; #[cfg(target_os = "macos")] { app.run_on_main_thread({ let window = window.clone(); - - #[allow(deprecated)] move || { - use tauri_nspanel::cocoa::appkit::{NSWindowCollectionBehavior,NSMainMenuWindowLevel}; - use tauri_nspanel::WebviewWindowExt as NSPanelWebviewWindowExt; - - if let Ok(panel) = window.to_panel() { - panel.set_level(NSMainMenuWindowLevel); - panel.set_collection_behaviour( - NSWindowCollectionBehavior::NSWindowCollectionBehaviorTransient - | NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace - | NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary - | NSWindowCollectionBehavior::NSWindowCollectionBehaviorIgnoresCycle, - ); - + use tauri_nspanel::cocoa::base::{id, YES}; + use tauri_nspanel::cocoa::appkit::{NSWindow, NSWindowButton}; + use tauri_nspanel::objc::{msg_send, sel, sel_impl}; + + // Hide traffic lights using cocoa APIs + if let Ok(ns_window) = window.ns_window() { + unsafe { + let ns_window: id = ns_window as *mut std::ffi::c_void as id; + + // Get and hide the standard window buttons only + let close_button: id = NSWindow::standardWindowButton_(ns_window, NSWindowButton::NSWindowCloseButton); + let miniaturize_button: id = NSWindow::standardWindowButton_(ns_window, NSWindowButton::NSWindowMiniaturizeButton); + let zoom_button: id = NSWindow::standardWindowButton_(ns_window, NSWindowButton::NSWindowZoomButton); + + if !close_button.is_null() { + let _: () = msg_send![close_button, setHidden: YES]; + } + if !miniaturize_button.is_null() { + let _: () = msg_send![miniaturize_button, setHidden: YES]; + } + if !zoom_button.is_null() { + let _: () = msg_send![zoom_button, setHidden: YES]; + } + + // Make title bar transparent instead of changing style mask + let _: () = msg_send![ns_window, setTitlebarAppearsTransparent: YES]; + let _: () = msg_send![ns_window, setMovableByWindowBackground: YES]; + } } } - }) - .ok(); + }).ok(); } crate::spawn_overlay_listener(app.clone(), window.clone()); diff --git a/plugins/windows/src/lib.rs b/plugins/windows/src/lib.rs index 8429e470df..ef1a2327d7 100644 --- a/plugins/windows/src/lib.rs +++ b/plugins/windows/src/lib.rs @@ -8,6 +8,7 @@ pub use errors::*; pub use events::*; pub use ext::*; use overlay::*; +pub use overlay::{FakeWindowBounds, OverlayBound}; const PLUGIN_NAME: &str = "windows"; @@ -47,6 +48,8 @@ fn make_specta_builder() -> tauri_specta::Builder { 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) } @@ -69,6 +72,11 @@ pub fn init() -> tauri::plugin::TauriPlugin { 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 index b789e6b38c..62af308d8c 100644 --- a/plugins/windows/src/overlay.rs +++ b/plugins/windows/src/overlay.rs @@ -2,7 +2,7 @@ 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)] +#[derive(Debug, Default, serde::Serialize, serde::Deserialize, specta::Type, Clone, Copy)] pub struct OverlayBound { pub x: f64, pub y: f64, @@ -15,19 +15,41 @@ pub struct OverlayState { pub bounds: Arc>>>, } +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) { window.set_ignore_cursor_events(true).ok(); tokio::spawn(async move { - let state = app.state::(); + let state = app.state::(); + let mut last_ignore_state = true; + let mut last_focus_state = false; loop { - sleep(Duration::from_millis(1000 / 20)).await; + // Reduced polling frequency from 20Hz to 10Hz + sleep(Duration::from_millis(1000 / 10)).await; - let map = state.bounds.read().await; + let map = state.0.read().await; let Some(windows) = map.get(window.label()) else { - window.set_ignore_cursor_events(true).ok(); + 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; }; @@ -36,18 +58,24 @@ pub fn spawn_overlay_listener(app: AppHandle, window: WebviewWindow) { window.cursor_position(), window.scale_factor(), ) else { - let _ = window.set_ignore_cursor_events(true); + if !last_ignore_state { + let _ = window.set_ignore_cursor_events(true); + last_ignore_state = true; + } continue; }; let mut ignore = true; - for bounds in windows.values() { + 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; + // println!("Checking bounds for {}: mouse({}, {}) vs bounds({}-{}, {}-{})", + // name, mouse_position.x, mouse_position.y, x_min, x_max, y_min, y_max); + if mouse_position.x >= x_min && mouse_position.x <= x_max && mouse_position.y >= y_min @@ -58,15 +86,18 @@ pub fn spawn_overlay_listener(app: AppHandle, window: WebviewWindow) { } } - window.set_ignore_cursor_events(ignore).ok(); + // Only update cursor events if state changed + if ignore != last_ignore_state { + window.set_ignore_cursor_events(ignore).ok(); + last_ignore_state = ignore; + } let focused = window.is_focused().unwrap_or(false); - if !ignore { - if !focused { - window.set_focus().ok(); - } - } else if focused { - window.set_ignore_cursor_events(ignore).ok(); + if !ignore && !focused && !last_focus_state { + window.set_focus().ok(); + last_focus_state = true; + } else if ignore && last_focus_state { + last_focus_state = false; } } });