From 3862e23cef8f332b2ec0158e0633ee28e8be094c Mon Sep 17 00:00:00 2001 From: Vlad Gerasimov Date: Sat, 2 Aug 2025 18:26:31 +0400 Subject: [PATCH 1/8] style(overlay): update recording overlay theme to pink palette --- src-tauri/src/utils.rs | 4 ++-- src/App.css | 1 + src/components/ui/ToggleSwitch.tsx | 2 +- src/overlay/RecordingOverlay.css | 13 ++++++------- src/overlay/RecordingOverlay.tsx | 8 ++++---- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index eae3d25..da463b8 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -325,7 +325,7 @@ pub fn create_recording_overlay(app_handle: &AppHandle) { if let Ok(monitors) = app_handle.primary_monitor() { if let Some(monitor) = monitors { const OVERLAY_WIDTH: f64 = 172.0; - const OVERLAY_HEIGHT: f64 = 00.0; + const OVERLAY_HEIGHT: f64 = 40.0; // Platform-specific bottom offset #[cfg(target_os = "windows")] @@ -352,7 +352,7 @@ pub fn create_recording_overlay(app_handle: &AppHandle) { .title("Recording") .position(x, y) .resizable(false) - .inner_size(172.0, 40.0) + .inner_size(OVERLAY_WIDTH, OVERLAY_HEIGHT) .shadow(false) .maximizable(false) .minimizable(false) diff --git a/src/App.css b/src/App.css index 8ba9c84..a026572 100644 --- a/src/App.css +++ b/src/App.css @@ -3,6 +3,7 @@ @theme { --color-text: #0f0f0f; --color-background: #fbfbfb; + --color-background-ui: #DA5893; --color-logo-primary: #FAA2CA; --color-logo-stroke: #382731; --color-text-stroke: #f6f6f6; diff --git a/src/components/ui/ToggleSwitch.tsx b/src/components/ui/ToggleSwitch.tsx index f081d78..95311ef 100644 --- a/src/components/ui/ToggleSwitch.tsx +++ b/src/components/ui/ToggleSwitch.tsx @@ -38,7 +38,7 @@ export const ToggleSwitch: React.FC = ({ disabled={disabled || isUpdating} onChange={(e) => onChange(e.target.checked)} /> -
+
{isUpdating && (
diff --git a/src/overlay/RecordingOverlay.css b/src/overlay/RecordingOverlay.css index fb3c72f..8d35514 100644 --- a/src/overlay/RecordingOverlay.css +++ b/src/overlay/RecordingOverlay.css @@ -6,7 +6,7 @@ align-items: center; padding-left: 6px; padding-right: 6px; - background: rgba(0, 0, 0, 0.6); + background: #DA5893DD; border-radius: 24px; opacity: 0; transition: opacity 300ms ease-out; @@ -40,9 +40,9 @@ .bar { width: 6px; - background: linear-gradient(to top, #faa2ca, #f28cbb); + background: #FFE5EE; max-height: 28px; - border-radius: 4px; + border-radius: 2px; transition: height 80ms linear; min-height: 4px; } @@ -52,9 +52,8 @@ } .transcribing-text { - color: #faa2ca; + color: white; font-size: 14px; - font-weight: 600; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; animation: transcribing-pulse 1.5s infinite ease-in-out; @@ -74,7 +73,7 @@ width: 28px; height: 28px; border-radius: 50%; - background: #e53e3e; + background: white; display: flex; align-items: center; justify-content: center; @@ -86,7 +85,7 @@ } .cancel-button:hover { - background: #c53030; + background: #FFDCEC; transform: scale(1.05); } diff --git a/src/overlay/RecordingOverlay.tsx b/src/overlay/RecordingOverlay.tsx index 6995f74..af5037c 100644 --- a/src/overlay/RecordingOverlay.tsx +++ b/src/overlay/RecordingOverlay.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useState, useRef } from "react"; -import "./RecordingOverlay.css"; -import { listen } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import React, { useEffect, useRef, useState } from "react"; +import "./RecordingOverlay.css"; type OverlayState = "recording" | "transcribing"; @@ -105,7 +105,7 @@ const RecordingOverlay: React.FC = () => { Date: Sat, 2 Aug 2025 11:04:49 -0700 Subject: [PATCH 2/8] dont use overlay height for calculation of y, it moves it too far up --- src-tauri/src/utils.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index da463b8..689dc19 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -342,7 +342,7 @@ pub fn create_recording_overlay(app_handle: &AppHandle) { // Position at bottom center of work area let x = work_area_x + (work_area_width - OVERLAY_WIDTH) / 2.0; - let y = work_area_y + work_area_height - OVERLAY_HEIGHT - OVERLAY_BOTTOM_OFFSET; + let y = work_area_y + work_area_height - OVERLAY_BOTTOM_OFFSET; match WebviewWindowBuilder::new( app_handle, From 53874110b18619eb9f7608d67678e58db2d7bf7d Mon Sep 17 00:00:00 2001 From: Vlad Gerasimov Date: Sun, 3 Aug 2025 11:36:00 +0400 Subject: [PATCH 3/8] style(overlay): update recording overlay design and positioning --- index.html | 25 ++++++++------ public/icon/recording.png | Bin 1461 -> 0 bytes public/icon/transcribing.png | Bin 1559 -> 0 bytes public/tauri.svg | 6 ---- public/vite.svg | 1 - src-tauri/resources/handy.png | Bin 0 -> 768 bytes src-tauri/resources/recording.png | Bin 0 -> 678 bytes src-tauri/resources/transcribing.png | Bin 0 -> 756 bytes src-tauri/src/utils.rs | 14 +++----- src/overlay/RecordingOverlay.css | 27 ++++++++------- src/overlay/RecordingOverlay.tsx | 49 ++++++++++++++------------- src/overlay/index.html | 8 +++++ 12 files changed, 65 insertions(+), 65 deletions(-) delete mode 100644 public/icon/recording.png delete mode 100644 public/icon/transcribing.png delete mode 100644 public/tauri.svg delete mode 100644 public/vite.svg create mode 100644 src-tauri/resources/handy.png create mode 100644 src-tauri/resources/recording.png create mode 100644 src-tauri/resources/transcribing.png diff --git a/index.html b/index.html index 7aab9a0..57bb847 100644 --- a/index.html +++ b/index.html @@ -1,14 +1,17 @@ - - - - - handy - - -
- - - + + + + handy + + + +
+ + + + \ No newline at end of file diff --git a/public/icon/recording.png b/public/icon/recording.png deleted file mode 100644 index 8839e844fea9cf30b70a0e8be6ef414d3e43e8dd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1461 zcmV;m1xosfP)D{b<-m z*EZeQ^oV>|#;r{ioip6iHZ?Y_F(0gqhRzLJscV~_aXQRdC<1P%8@wHc>p-UlZn)p# zeBin)Xaee00L~b#X>7&cn$D(=+EwG7CNtb{d!+MgJX6{9h`FYO75oW#tZllp=?cP< zW{)l5YzpVeT|PfiNie-vj#y&6?hPt(dxD;$zCWg|K%GsOR_+UFfPS4fF>S;7 z4J)L%$p_tCxKU}KPzg0D6kwoZ8Ie=MTfC)0okS_X?dUYL z5jkIkylj;2eB~E|ybFJft-TSh`w}VR4mwtppa7!~Xdoee|gH=?amdiE$6NLY{x1qm~NN8k9zmVFu?#s|&ZUkb2A@zU&R=w^V-q-NSOkj6DnCvEJK>-;kA;Iw0o9q8GS%RyIt*s zV;=^;Uj(22iqhbU?KRNL>LO+B$|M~%e@srs@BN>ObX3BzZ^3_FRqp-o z)cN0YH~ixb*0P{{W-%;4i?j-?P=;2JtzGAn$WZ`O1(mFkkLadirn^rpTXkO`U~kyj zrD|ipHZ+~iroY02ww~8C_k9RVV=c(Z83m0Xj~$@dZn_Q&GMGj!RC6hOYC_z;={pTw`p@>=m$~JUAdR^51R4xEDLp(>p1jt&HDo z`e=V&QeH=eyeb_oLQ{e|ECp#sQ3`WOvhGoiDimJgiLTMTIHbkwlIA<~p|2RbfXL7h z`j_&N_VPSzyCc_er0C#=%qw<^&oA*`|6{0t4V6LK_a9qjDx21b-(K6Er*iBzTm5cu z>&n`KHl*KHhDbe`V!YUhvJ&pe^-$-J@e`&EmD#d0dv~DU2PePM+bMvlY-(v<_Lk;w zGxR_OmKtF`cXS?fw)gLc2lf=;4VKo8l?Z);B8n}*sBNR|u=nLV%A*csa^sV{LJwDz zx5i(o9HZWkg8bmdb42bs;o4PjO)2N6CJoTvDhpXf?}1x{u1G^yI4C*KO{AT zhyA7W&=X#xxRpP|c^;$(nc$uaXlObeO?gPl7oHoDG01q)0q(AG4o4r8OQ}SzJup)$ zf%siy4KsCJQ6)Vj#pg-{K^vL_U&&Lx8ly%1JkqZh zCP;}dfWuQ7&u(tGE|({N#}Gg3OH(0m2GZbk@LZoeY_7*EqC{*l#@d%rDmXF^a2{ld zcYokz@cyBu892qu(-pb+04IJFr+;y}3bJTP)N1vyucQw2Fy5T$~gE3i5sU4iHdC|4k*f@3P6T*0ve(iJ>o4bXzY1+cgy zDSGTTgW(7E?Jm9-I2_SWjtQL@0H)@nG3hUp_t3Y()($1t$COvVQOeP!p(O?SQ zm^3qKjqzX|cP3SI$>5eYsWqv?>A;oI(xt&T_0FU%&WHXYDgw5v_joz<>;s(}u-*L* zmjm`?N&`^53cyZA9Uac%FO7{!*QKk*ZzaO8-EB>mS9nHc(i(+BLR|qy@xNqSeo$9C z6WF)T`Kp3y2snlJR0a-cHtu^x3H=HAAsQM3 zXH>hs-xlbdpMWoceXnQ;V3)dHX{0Vx&-!b}9m~(5$B-ow`gOjJ@VNpjBnk&RyvFi1 zxTC_tYZcE0E<62z!`4|s(H%?q&<}@(w1BSb_n||`cT47^TC|uC+f!6Sfs}=Bp~EuZ zRnX5FQ^D?FzWKd?4*7`|`emjGcuVLp7wit^LqBB2${*7Dz+o$t9DekI?ZJH5VW36p zb0?t_WRI+J*qJEnb-+a^W6}rr`Hq79gW*q3%#UGZsMiPo!j3@=I>@teh}z3L=%Od! z?^h2E8mR&_6Um$eK_t>~<58*g}w# zaFfv;w(@O|-yn{qFW5zdwt~^#%YV!`811%#tY{yZtukznNckHbHk`jd6RaZop8{n~ zAp1MT9pm6->>1+zj4u_#|KZCE6}F>_E;cit|9eXRG^wTIogA~y zJ6EB6#<%kF{wDk2)dyFA-*BI0wbs8^G#>Lr9uJY7Gq!&!tO0XY?vpt;PRGop;{tJn zTi)Dq>El+g)9lA=#D@+-n2NGZS`%=i6MSe~!a7i<-gQyB!W+$hLB1C1U8*nC7bv`k z4hQi@~lf0LqOj%tB|M}cRbcwr6Z}BL7G?kgnUzAQ!_Vx>mC06fd3RWxD}QV zw#X6L7%Js|8eLxjJF7pCF529O?$4*fu9iE}JCE;=`>*jk#S0~!DsL(7{KlmB z*@Y*QCdX zXb$ub8X3|N)Lg-vC+rX6LmxQhACSU^uUa$(h2V<4RoE}$qim?#qS7f8Djr+|9r8Vi#1y>{a zdx#q^AK9CtD31^?3D?qY(MR^Kuv74semuO*6}|{4TW?<)Eu8`wb`*9_uy0LCnSQ-6 zL4|Mzt|+ysI6v5ztFymjh@bVPsSqf?$3GUTjn&!VDx&mEzXYALQM`9JD_~>w1&;UX zEN_t;Iswk{@^nSdVJwHV+ - - - - - diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src-tauri/resources/handy.png b/src-tauri/resources/handy.png new file mode 100644 index 0000000000000000000000000000000000000000..7a1e4767703fba0230a31f35204b8447c97f0d2e GIT binary patch literal 768 zcmV+b1ONPqP)u2$2k=BDTMgE7`2YAp?nL#>e^HU^teG`+38v>4GrhbmJ#Pyjgp zjQ{tj5(3N@3lrJ_Ee$lVToKU%6?}Zvk|RJ9kwr|RP{s`n2J4Un8xC+w2a*7a4??>w z2D{o05C*$H9LS434H}e4)=m!GF4j(QO`&x6Sm@?hwx>{p5Tl8c`>YHNj4l^}Q$L26 zN<~6bOVML_3|1zRlgoM(eSQ>*1}|Ji_6opJ^sot6bveQuz=oKP^@zt^>@%%#UHAAH zQz~M6?vp*ofEHEn_ z_Qu299P4$oSP_|!*K7J-$2$hm?^_LY|f7lw8LaMN3{zO?ncK*-FdFTxDowCn)=*LI1^Fh~}%8 zouJImDCnn;ZEUT+dkTI~$L~m(K~Lfrm3EXiLnZUq4lJd;qLQXGhf3a1$_J_~Q$1LZ yddm%6=rwM$)Rqp+v-0>4d!-(oQu{*VySsl7z$jmRZsz6y00000000abW%=J06>4Q-~YdWFc8mRZ|@)=uaGdHARusXpPvx#P*9Mc&ySCw$=B;V z0005SNkl4K6e^GY1JrZm1Z;*zSORdR>7E?S-;()5GYy5AqUOOkJfjKDahXn=e_lfyE_ zYpBGslE+uEC<>B`<(l$xRgFla>Z{!viUYI_j=81e{9Qv+(sNF-akl2wp@dd$kPS`G zu^D8WKUW^Xn@iY*Z!m&XG-s{*M)sFlHok`=n2B1k{4h=lJ0fGRKj|62;O*%ukCzA@ zaWOr?>?hediQswH^ehPlf;4boME2+;lpSzhWuPp5=IVt;g4IC^pPr0 zlAaQS+Ml#OH(^b literal 0 HcmV?d00001 diff --git a/src-tauri/resources/transcribing.png b/src-tauri/resources/transcribing.png new file mode 100644 index 0000000000000000000000000000000000000000..4456ceb54d8fe5c024b82d7589d5f19bfd2e4fe3 GIT binary patch literal 756 zcmVSRy`J&1Bp~?53 z$N!(t`Ju`9p~(54$@`6$@-(p zM$Ytw0000abW%=J01z-BkKg~FAdi2~@4rwGV1OVX?{F|c&v1~SpRbTmpRlj5wX8&H z0006LNklXc&@)UiQNOVwb}Q2~37a zFE9Tyv)PzZF8j8%xUsvpTweE%r+t#w8HX8XmYLSvudl3w@XJgq_VTuMLqx$s=-jT< z&jN4_iOxZ&Tp20}0PKM#%@Jwtsl)>aF}6svr;;OpA;u1AuBgD^Mh^B{>6(20E4Ggd zPSd02vaO3th$`Ng{y&x)au3m#2KZiR=^B50$eJ?%H^0#WU@7AnkmH10Q?VQx3t*fN zaE{->a0FmYwuNNHHr!j8WIg~(^#ZdTXWQu{lgi{`B(-c82$}ktYo`V`2dRW`jUJ$J zxC_&@hBTf=WyD&l#%Zn)JU#7?ObLEUyL*3W4yP;)Fez3b4@RrObQ9lg4HY-L?^pDLX^ zt(l*>VB$9vnXjp3a*qJ}rca3vRVBxtT2b=!d#l-&w?1!R{n(vuWjqj`-{w~zq{`2y_W$?PI`tKX?9ew+$%iN zJW>g(J$DbJd7vU6?=bHWI*(M=-G7jGeZQ^bXqgXGCcAc24a-cc28$; { setupEventListeners(); }, []); - const getIconPath = () => { - return state === "recording" - ? "/icon/recording.png" - : "/icon/transcribing.png"; + const getIcon = () => { + if (state === "recording") { + return ( + + + + + + ); + } else { + return ( + + + + + + + + + + ); + } }; - const getIconAlt = () => { - return state === "recording" ? "Recording Icon" : "Transcribing Icon"; - }; return (
- {getIconAlt()} + {getIcon()}
@@ -81,7 +90,7 @@ const RecordingOverlay: React.FC = () => { key={i} className="bar" style={{ - height: `${4 + Math.pow(v, 0.7) * 32}px`, // Slight curve for better visual + height: `${Math.min(20, 4 + Math.pow(v, 0.7) * 16)}px`, // Cap at 20px max height transition: "height 60ms ease-out", opacity: Math.max(0.4, v * 1.7), // Minimum opacity for visibility }} @@ -102,15 +111,7 @@ const RecordingOverlay: React.FC = () => { invoke("cancel_operation"); }} > - - - +
)}
diff --git a/src/overlay/index.html b/src/overlay/index.html index 08db593..ef2c670 100644 --- a/src/overlay/index.html +++ b/src/overlay/index.html @@ -9,6 +9,14 @@ margin: 0; padding: 0; background: transparent; + overflow: hidden; + width: 100%; + height: 100%; + } + #root { + width: 100%; + height: 100%; + overflow: hidden; } From 091b392aa62528a555168596d33f01b0b19d015e Mon Sep 17 00:00:00 2001 From: Vlad Gerasimov Date: Wed, 6 Aug 2025 07:52:09 +0400 Subject: [PATCH 4/8] feat(overlay): replace show_overlay toggle with position selector --- src-tauri/src/lib.rs | 2 +- src-tauri/src/settings.rs | 12 +- src-tauri/src/shortcut.rs | 8 +- src-tauri/src/utils.rs | 110 +++++++++++------- .../settings/MicrophoneSelector.tsx | 11 +- .../settings/OutputDeviceSelector.tsx | 11 +- src/components/settings/ShowOverlay.tsx | 29 +++-- src/components/ui/Dropdown.tsx | 102 +++++----------- src/hooks/useSettings.ts | 6 +- src/lib/types.ts | 2 +- 10 files changed, 149 insertions(+), 144 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 68b5b52..6117d3e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -181,7 +181,7 @@ pub fn run() { shortcut::change_audio_feedback_setting, shortcut::change_translate_to_english_setting, shortcut::change_selected_language_setting, - shortcut::change_show_overlay_setting, + shortcut::change_overlay_position_setting, shortcut::change_debug_mode_setting, shortcut::suspend_binding, shortcut::resume_binding, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 3e3fa6a..983af26 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -30,8 +30,8 @@ pub struct AppSettings { pub translate_to_english: bool, #[serde(default = "default_selected_language")] pub selected_language: String, - #[serde(default = "default_show_overlay")] - pub show_overlay: bool, + #[serde(default = "default_overlay_position")] + pub overlay_position: String, #[serde(default = "default_debug_mode")] pub debug_mode: bool, } @@ -58,9 +58,9 @@ fn default_selected_language() -> String { "auto".to_string() } -fn default_show_overlay() -> bool { - // Default to true - users expect visual feedback by default - true +fn default_overlay_position() -> String { + // Default to "bottom" - less intrusive position + "bottom".to_string() } fn default_debug_mode() -> bool { @@ -114,7 +114,7 @@ pub fn get_default_settings() -> AppSettings { selected_output_device: None, translate_to_english: false, selected_language: "auto".to_string(), - show_overlay: true, + overlay_position: "bottom".to_string(), debug_mode: false, } } diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index 7dc7da2..5017c13 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -135,10 +135,14 @@ pub fn change_selected_language_setting(app: AppHandle, language: String) -> Res } #[tauri::command] -pub fn change_show_overlay_setting(app: AppHandle, enabled: bool) -> Result<(), String> { +pub fn change_overlay_position_setting(app: AppHandle, position: String) -> Result<(), String> { let mut settings = settings::get_settings(&app); - settings.show_overlay = enabled; + settings.overlay_position = position; settings::write_settings(&app, settings); + + // Update overlay position without recreating window + crate::utils::update_overlay_position(&app); + Ok(()) } diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 0c09739..e22d438 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -338,53 +338,66 @@ fn play_audio_file( /* OVERLAY MANAGEMENT */ /* ──────────────────────────────────────────────────────────────── */ -/// Creates the recording overlay window and keeps it hidden by default -pub fn create_recording_overlay(app_handle: &AppHandle) { - // Get work area dimensions for positioning (respects taskbars, docks, etc.) +const OVERLAY_WIDTH: f64 = 172.0; +const OVERLAY_HEIGHT: f64 = 36.0; +const OVERLAY_OFFSET: f64 = 46.0; + +fn calculate_overlay_position(app_handle: &AppHandle) -> Option<(f64, f64)> { if let Ok(monitors) = app_handle.primary_monitor() { if let Some(monitor) = monitors { - const OVERLAY_WIDTH: f64 = 172.0; - const OVERLAY_HEIGHT: f64 = 36.0; - const OVERLAY_TOP_OFFSET: f64 = 46.0; - let work_area = monitor.work_area(); let scale = monitor.scale_factor(); let work_area_width = work_area.size.width as f64 / scale; + let work_area_height = work_area.size.height as f64 / scale; let work_area_x = work_area.position.x as f64 / scale; let work_area_y = work_area.position.y as f64 / scale; - // Position at top center of work area - let x = work_area_x + (work_area_width - OVERLAY_WIDTH) / 2.0; - let y = work_area_y + OVERLAY_TOP_OFFSET; + let settings = settings::get_settings(app_handle); + let overlay_position = settings.overlay_position.as_str(); - match WebviewWindowBuilder::new( - app_handle, - "recording_overlay", - tauri::WebviewUrl::App("src/overlay/index.html".into()), - ) - .title("Recording") - .position(x, y) - .resizable(false) - .inner_size(OVERLAY_WIDTH, OVERLAY_HEIGHT) - .shadow(false) - .maximizable(false) - .minimizable(false) - .closable(false) - .accept_first_mouse(true) - .decorations(false) - .always_on_top(true) - .skip_taskbar(true) - .transparent(true) - .focused(false) - .visible(false) // Start hidden - .build() - { - Ok(_window) => { - debug!("Recording overlay window created successfully (hidden)"); - } - Err(e) => { - debug!("Failed to create recording overlay window: {}", e); - } + let x = work_area_x + (work_area_width - OVERLAY_WIDTH) / 2.0; + let y = match overlay_position { + "top" => work_area_y + OVERLAY_OFFSET, + "bottom" => work_area_y + work_area_height - OVERLAY_HEIGHT - OVERLAY_OFFSET, + _ => work_area_y + work_area_height - OVERLAY_HEIGHT - OVERLAY_OFFSET, + }; + + return Some((x, y)); + } + } + None +} + +/// Creates the recording overlay window and keeps it hidden by default +pub fn create_recording_overlay(app_handle: &AppHandle) { + if let Some((x, y)) = calculate_overlay_position(app_handle) { + match WebviewWindowBuilder::new( + app_handle, + "recording_overlay", + tauri::WebviewUrl::App("src/overlay/index.html".into()), + ) + .title("Recording") + .position(x, y) + .resizable(false) + .inner_size(OVERLAY_WIDTH, OVERLAY_HEIGHT) + .shadow(false) + .maximizable(false) + .minimizable(false) + .closable(false) + .accept_first_mouse(true) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .transparent(true) + .focused(false) + .visible(false) + .build() + { + Ok(_window) => { + debug!("Recording overlay window created successfully (hidden)"); + } + Err(e) => { + debug!("Failed to create recording overlay window: {}", e); } } } @@ -392,9 +405,9 @@ pub fn create_recording_overlay(app_handle: &AppHandle) { /// Shows the recording overlay window with fade-in animation pub fn show_recording_overlay(app_handle: &AppHandle) { - // Check if show_overlay is enabled in settings + // Check if overlay should be shown based on position setting let settings = settings::get_settings(app_handle); - if !settings.show_overlay { + if settings.overlay_position == "none" { return; } @@ -407,9 +420,9 @@ pub fn show_recording_overlay(app_handle: &AppHandle) { /// Shows the transcribing overlay window pub fn show_transcribing_overlay(app_handle: &AppHandle) { - // Check if show_overlay is enabled in settings + // Check if overlay should be shown based on position setting let settings = settings::get_settings(app_handle); - if !settings.show_overlay { + if settings.overlay_position == "none" { return; } @@ -420,10 +433,19 @@ pub fn show_transcribing_overlay(app_handle: &AppHandle) { } } +/// Updates the overlay window position based on current settings +pub fn update_overlay_position(app_handle: &AppHandle) { + if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { + if let Some((x, y)) = calculate_overlay_position(app_handle) { + let _ = overlay_window.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); + } + } +} + /// Hides the recording overlay window with fade-out animation pub fn hide_recording_overlay(app_handle: &AppHandle) { - // Check if show_overlay is enabled in settings - if disabled, the overlay shouldn't be shown anyway - // but we still want to hide it in case the setting was changed while recording + // Always hide the overlay regardless of settings - if setting was changed while recording, + // we still want to hide it properly if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { // Emit event to trigger fade-out animation let _ = overlay_window.emit("hide-overlay", ()); diff --git a/src/components/settings/MicrophoneSelector.tsx b/src/components/settings/MicrophoneSelector.tsx index 1ddf5eb..a958719 100644 --- a/src/components/settings/MicrophoneSelector.tsx +++ b/src/components/settings/MicrophoneSelector.tsx @@ -55,6 +55,11 @@ export const MicrophoneSelector: React.FC = ({ console.log("Microphone reset to default"); }; + const microphoneOptions = audioDevices.map(device => ({ + value: device.name, + label: device.name + })); + return ( = ({ >
- {isOpen && !disabled && (
- {devices.length === 0 ? ( -
- No microphones found -
+ {options.length === 0 ? ( +
No options found
) : ( - devices.map((device) => ( + options.map((option) => ( )) )} diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 2223ba8..17588bc 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -199,8 +199,8 @@ export const useSettings = (): UseSettingsReturn => { language: value, }); break; - case "show_overlay": - await invoke("change_show_overlay_setting", { enabled: value }); + case "overlay_position": + await invoke("change_overlay_position_setting", { position: value }); break; case "debug_mode": await invoke("change_debug_mode_setting", { enabled: value }); @@ -249,7 +249,7 @@ export const useSettings = (): UseSettingsReturn => { selected_output_device: "Default", translate_to_english: false, selected_language: "auto", - show_overlay: true, + overlay_position: "bottom", debug_mode: false, }; diff --git a/src/lib/types.ts b/src/lib/types.ts index 5ac069c..4ecd42e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -29,7 +29,7 @@ export const SettingsSchema = z.object({ selected_output_device: z.string().nullable().optional(), translate_to_english: z.boolean(), selected_language: z.string(), - show_overlay: z.boolean(), + overlay_position: z.string(), debug_mode: z.boolean(), }); From c0fc1ba817a8ef09f55b7ea24fd34bc8a7d881e1 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Thu, 7 Aug 2025 16:53:07 -0700 Subject: [PATCH 5/8] some cleanup --- index.html | 26 +- src-tauri/src/actions.rs | 9 +- src-tauri/src/audio_feedback.rs | 106 +++++ src-tauri/src/clipboard.rs | 64 +++ src-tauri/src/lib.rs | 4 + src-tauri/src/overlay.rs | 135 +++++++ src-tauri/src/settings.rs | 40 +- src-tauri/src/shortcut.rs | 13 +- src-tauri/src/tray.rs | 115 ++++++ src-tauri/src/utils.rs | 436 +-------------------- src/components/icons/CancelIcon.tsx | 37 ++ src/components/icons/MicrophoneIcon.tsx | 37 ++ src/components/icons/TranscriptionIcon.tsx | 53 +++ src/components/icons/index.ts | 3 + src/components/settings/HandyShortcut.tsx | 1 - src/components/settings/ShowOverlay.tsx | 5 +- src/lib/types.ts | 5 +- src/overlay/RecordingOverlay.tsx | 32 +- 18 files changed, 620 insertions(+), 501 deletions(-) create mode 100644 src-tauri/src/audio_feedback.rs create mode 100644 src-tauri/src/clipboard.rs create mode 100644 src-tauri/src/overlay.rs create mode 100644 src-tauri/src/tray.rs create mode 100644 src/components/icons/CancelIcon.tsx create mode 100644 src/components/icons/MicrophoneIcon.tsx create mode 100644 src/components/icons/TranscriptionIcon.tsx create mode 100644 src/components/icons/index.ts diff --git a/index.html b/index.html index 57bb847..330a7e4 100644 --- a/index.html +++ b/index.html @@ -1,17 +1,13 @@ - + + + + + handy + - - - - handy - - - -
- - - - \ No newline at end of file + +
+ + + diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 53b2a08..560d2b4 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -1,13 +1,10 @@ +use crate::audio_feedback::{play_recording_start_sound, play_recording_stop_sound}; use crate::managers::audio::AudioRecordingManager; use crate::managers::transcription::TranscriptionManager; +use crate::overlay::{show_recording_overlay, show_transcribing_overlay}; use crate::settings::get_settings; +use crate::tray::{change_tray_icon, TrayIconState}; use crate::utils; -use crate::utils::change_tray_icon; -use crate::utils::play_recording_start_sound; -use crate::utils::play_recording_stop_sound; -use crate::utils::show_recording_overlay; -use crate::utils::show_transcribing_overlay; -use crate::utils::TrayIconState; use log::debug; use once_cell::sync::Lazy; use std::collections::HashMap; diff --git a/src-tauri/src/audio_feedback.rs b/src-tauri/src/audio_feedback.rs new file mode 100644 index 0000000..feaa00f --- /dev/null +++ b/src-tauri/src/audio_feedback.rs @@ -0,0 +1,106 @@ +use crate::settings; +use cpal::traits::{DeviceTrait, HostTrait}; +use rodio::OutputStreamBuilder; +use std::fs::File; +use std::io::BufReader; +use std::thread; +use tauri::{AppHandle, Manager}; + +/// Plays an audio resource from the resources directory. +/// Checks if audio feedback is enabled in settings before playing. +pub fn play_sound(app: &AppHandle, resource_path: &str) { + // Check if audio feedback is enabled + let settings = settings::get_settings(app); + if !settings.audio_feedback { + return; + } + + let app_handle = app.clone(); + let resource_path = resource_path.to_string(); + + // Spawn a new thread to play the audio without blocking the main thread + thread::spawn(move || { + // Get the path to the audio file in resources + let audio_path = match app_handle + .path() + .resolve(&resource_path, tauri::path::BaseDirectory::Resource) + { + Ok(path) => path.to_path_buf(), + Err(e) => { + eprintln!( + "Failed to resolve audio file path '{}': {}", + resource_path, e + ); + return; + } + }; + + // Get the selected output device from settings + let settings = settings::get_settings(&app_handle); + let selected_device = settings.selected_output_device.clone(); + + // Try to play the audio file + if let Err(e) = play_audio_file(&audio_path, selected_device) { + eprintln!("Failed to play sound '{}': {}", resource_path, e); + } + }); +} + +/// Convenience function to play the recording start sound +pub fn play_recording_start_sound(app: &AppHandle) { + play_sound(app, "resources/rec_start.wav"); +} + +/// Convenience function to play the recording stop sound +pub fn play_recording_stop_sound(app: &AppHandle) { + play_sound(app, "resources/rec_stop.wav"); +} + +fn play_audio_file( + path: &std::path::Path, + selected_device: Option, +) -> Result<(), Box> { + let stream_builder = if let Some(device_name) = selected_device { + if device_name == "Default" { + println!("Using default device"); + // Use default device + OutputStreamBuilder::from_default_device()? + } else { + // Try to find the device by name + let host = cpal::default_host(); + let devices = host.output_devices()?; + + let mut found_device = None; + for device in devices { + if device.name()? == device_name { + found_device = Some(device); + break; + } + } + + match found_device { + Some(device) => OutputStreamBuilder::from_device(device)?, + None => { + eprintln!("Device '{}' not found, using default device", device_name); + OutputStreamBuilder::from_default_device()? + } + } + } + } else { + println!("Using default device"); + // Use default device + OutputStreamBuilder::from_default_device()? + }; + + let stream_handle = stream_builder.open_stream()?; + let mixer = stream_handle.mixer(); + + // Load the audio file + let file = File::open(path)?; + let buf_reader = BufReader::new(file); + + let sink = rodio::play(mixer, buf_reader)?; + sink.sleep_until_end(); + + Ok(()) +} diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs new file mode 100644 index 0000000..c928351 --- /dev/null +++ b/src-tauri/src/clipboard.rs @@ -0,0 +1,64 @@ +use enigo::Enigo; +use enigo::Key; +use enigo::Keyboard; +use enigo::Settings; +use tauri::AppHandle; +use tauri_plugin_clipboard_manager::ClipboardExt; + +/// Sends a paste command (Cmd+V or Ctrl+V) using platform-specific virtual key codes. +/// This ensures the paste works regardless of keyboard layout (e.g., Russian, AZERTY, DVORAK). +fn send_paste() -> Result<(), String> { + // Platform-specific key definitions + #[cfg(target_os = "macos")] + let (modifier_key, v_key_code) = (Key::Meta, Key::Other(9)); + #[cfg(target_os = "windows")] + let (modifier_key, v_key_code) = (Key::Control, Key::Other(0x56)); // VK_V + #[cfg(target_os = "linux")] + let (modifier_key, v_key_code) = (Key::Control, Key::Unicode('v')); + + let mut enigo = Enigo::new(&Settings::default()) + .map_err(|e| format!("Failed to initialize Enigo: {}", e))?; + + // Press modifier + V + enigo + .key(modifier_key, enigo::Direction::Press) + .map_err(|e| format!("Failed to press modifier key: {}", e))?; + enigo + .key(v_key_code, enigo::Direction::Press) + .map_err(|e| format!("Failed to press V key: {}", e))?; + + // Release V + modifier (reverse order) + enigo + .key(v_key_code, enigo::Direction::Release) + .map_err(|e| format!("Failed to release V key: {}", e))?; + enigo + .key(modifier_key, enigo::Direction::Release) + .map_err(|e| format!("Failed to release modifier key: {}", e))?; + + Ok(()) +} + +pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { + let clipboard = app_handle.clipboard(); + + // get the current clipboard content + let clipboard_content = clipboard.read_text().unwrap_or_default(); + + clipboard + .write_text(&text) + .map_err(|e| format!("Failed to write to clipboard: {}", e))?; + + // small delay to ensure the clipboard content has been written to + std::thread::sleep(std::time::Duration::from_millis(50)); + + send_paste()?; + + std::thread::sleep(std::time::Duration::from_millis(50)); + + // restore the clipboard + clipboard + .write_text(&clipboard_content) + .map_err(|e| format!("Failed to restore clipboard: {}", e))?; + + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6117d3e..e220fac 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,9 +1,13 @@ mod actions; +mod audio_feedback; pub mod audio_toolkit; +mod clipboard; mod commands; mod managers; +mod overlay; mod settings; mod shortcut; +mod tray; mod utils; use managers::audio::AudioRecordingManager; diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs new file mode 100644 index 0000000..f875a72 --- /dev/null +++ b/src-tauri/src/overlay.rs @@ -0,0 +1,135 @@ +use crate::settings; +use crate::settings::OverlayPosition; +use log::debug; +use tauri::{AppHandle, Emitter, Manager, WebviewWindowBuilder}; + +const OVERLAY_WIDTH: f64 = 172.0; +const OVERLAY_HEIGHT: f64 = 36.0; +const OVERLAY_OFFSET: f64 = 46.0; + +fn calculate_overlay_position(app_handle: &AppHandle) -> Option<(f64, f64)> { + if let Ok(monitors) = app_handle.primary_monitor() { + if let Some(monitor) = monitors { + let work_area = monitor.work_area(); + let scale = monitor.scale_factor(); + let work_area_width = work_area.size.width as f64 / scale; + let work_area_height = work_area.size.height as f64 / scale; + let work_area_x = work_area.position.x as f64 / scale; + let work_area_y = work_area.position.y as f64 / scale; + + let settings = settings::get_settings(app_handle); + + let x = work_area_x + (work_area_width - OVERLAY_WIDTH) / 2.0; + let y = match settings.overlay_position { + OverlayPosition::Top => work_area_y + OVERLAY_OFFSET, + OverlayPosition::Bottom | OverlayPosition::None => { + work_area_y + work_area_height - OVERLAY_OFFSET + } + }; + + return Some((x, y)); + } + } + None +} + +/// Creates the recording overlay window and keeps it hidden by default +pub fn create_recording_overlay(app_handle: &AppHandle) { + if let Some((x, y)) = calculate_overlay_position(app_handle) { + match WebviewWindowBuilder::new( + app_handle, + "recording_overlay", + tauri::WebviewUrl::App("src/overlay/index.html".into()), + ) + .title("Recording") + .position(x, y) + .resizable(false) + .inner_size(OVERLAY_WIDTH, OVERLAY_HEIGHT) + .shadow(false) + .maximizable(false) + .minimizable(false) + .closable(false) + .accept_first_mouse(true) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .transparent(true) + .focused(false) + .visible(false) + .build() + { + Ok(_window) => { + debug!("Recording overlay window created successfully (hidden)"); + } + Err(e) => { + debug!("Failed to create recording overlay window: {}", e); + } + } + } +} + +/// Shows the recording overlay window with fade-in animation +pub fn show_recording_overlay(app_handle: &AppHandle) { + // Check if overlay should be shown based on position setting + let settings = settings::get_settings(app_handle); + if settings.overlay_position == OverlayPosition::None { + return; + } + + if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { + let _ = overlay_window.show(); + // Emit event to trigger fade-in animation with recording state + let _ = overlay_window.emit("show-overlay", "recording"); + } +} + +/// Shows the transcribing overlay window +pub fn show_transcribing_overlay(app_handle: &AppHandle) { + // Check if overlay should be shown based on position setting + let settings = settings::get_settings(app_handle); + if settings.overlay_position == OverlayPosition::None { + return; + } + + if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { + let _ = overlay_window.show(); + // Emit event to switch to transcribing state + let _ = overlay_window.emit("show-overlay", "transcribing"); + } +} + +/// Updates the overlay window position based on current settings +pub fn update_overlay_position(app_handle: &AppHandle) { + if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { + if let Some((x, y)) = calculate_overlay_position(app_handle) { + let _ = overlay_window + .set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); + } + } +} + +/// Hides the recording overlay window with fade-out animation +pub fn hide_recording_overlay(app_handle: &AppHandle) { + // Always hide the overlay regardless of settings - if setting was changed while recording, + // we still want to hide it properly + if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { + // Emit event to trigger fade-out animation + let _ = overlay_window.emit("hide-overlay", ()); + // Hide the window after a short delay to allow animation to complete + let window_clone = overlay_window.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(300)); + let _ = window_clone.hide(); + }); + } +} + +pub fn emit_levels(app_handle: &AppHandle, levels: &Vec) { + // emit levels to main app + let _ = app_handle.emit("mic-level", levels); + + // also emit to the recording overlay if it's open + if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { + let _ = overlay_window.emit("mic-level", levels); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 983af26..99d1520 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -12,6 +12,14 @@ pub struct ShortcutBinding { pub current_binding: String, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum OverlayPosition { + None, + Top, + Bottom, +} + /* still handy for composing the initial JSON in the store ------------- */ #[derive(Serialize, Deserialize, Debug, Clone)] pub struct AppSettings { @@ -31,55 +39,46 @@ pub struct AppSettings { #[serde(default = "default_selected_language")] pub selected_language: String, #[serde(default = "default_overlay_position")] - pub overlay_position: String, + pub overlay_position: OverlayPosition, #[serde(default = "default_debug_mode")] pub debug_mode: bool, } fn default_model() -> String { - // Default to empty string if no models are available yet - // The UI will handle prompting for model download "".to_string() } fn default_always_on_microphone() -> bool { - // Default to false for better user experience - // True would be the old behavior (always-on for low latency) false } fn default_translate_to_english() -> bool { - // Default to false - users need to opt-in to translation false } fn default_selected_language() -> String { - // Default to auto-detection for backward compatibility "auto".to_string() } -fn default_overlay_position() -> String { - // Default to "bottom" - less intrusive position - "bottom".to_string() +fn default_overlay_position() -> OverlayPosition { + OverlayPosition::Bottom } fn default_debug_mode() -> bool { - // Default to false - debug mode should be opt-in false } pub const SETTINGS_STORE_PATH: &str = "settings_store.json"; pub fn get_default_settings() -> AppSettings { - // Set platform-specific default keyboard shortcuts #[cfg(target_os = "windows")] let default_shortcut = "ctrl+space"; #[cfg(target_os = "macos")] - let default_shortcut = "alt+space"; // Alt key on macOS (Option key) + let default_shortcut = "alt+space"; #[cfg(target_os = "linux")] let default_shortcut = "ctrl+space"; #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] - let default_shortcut = "alt+space"; // Fallback for other platforms + let default_shortcut = "alt+space"; let mut bindings = HashMap::new(); bindings.insert( @@ -93,17 +92,6 @@ pub fn get_default_settings() -> AppSettings { }, ); - // bindings.insert( - // "test".to_string(), - // ShortcutBinding { - // id: "test".to_string(), - // name: "Test".to_string(), - // description: "This is a test binding.".to_string(), - // default_binding: "ctrl+d".to_string(), - // current_binding: "ctrl+d".to_string(), - // }, - // ); - AppSettings { bindings, push_to_talk: true, @@ -114,7 +102,7 @@ pub fn get_default_settings() -> AppSettings { selected_output_device: None, translate_to_english: false, selected_language: "auto".to_string(), - overlay_position: "bottom".to_string(), + overlay_position: OverlayPosition::Bottom, debug_mode: false, } } diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index 5017c13..189f33d 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -5,7 +5,7 @@ use tauri_plugin_global_shortcut::{Shortcut, ShortcutState}; use crate::actions::ACTION_MAP; use crate::settings::ShortcutBinding; -use crate::settings::{self, get_settings}; +use crate::settings::{self, get_settings, OverlayPosition}; use crate::ManagedToggleState; pub fn init_shortcuts(app: &App) { @@ -137,7 +137,16 @@ pub fn change_selected_language_setting(app: AppHandle, language: String) -> Res #[tauri::command] pub fn change_overlay_position_setting(app: AppHandle, position: String) -> Result<(), String> { let mut settings = settings::get_settings(&app); - settings.overlay_position = position; + let parsed = match position.as_str() { + "none" => OverlayPosition::None, + "top" => OverlayPosition::Top, + "bottom" => OverlayPosition::Bottom, + other => { + eprintln!("Invalid overlay position '{}', defaulting to bottom", other); + OverlayPosition::Bottom + } + }; + settings.overlay_position = parsed; settings::write_settings(&app, settings); // Update overlay position without recreating window diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 0000000..dcd7312 --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,115 @@ +use tauri::image::Image; +use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +use tauri::tray::TrayIcon; +use tauri::{AppHandle, Manager, Theme}; + +#[derive(Clone, Debug, PartialEq)] +pub enum TrayIconState { + Idle, + Recording, + Transcribing, +} + +/// Gets the current system theme, defaulting to Dark if unavailable +fn get_current_theme(app: &AppHandle) -> Theme { + if let Some(main_window) = app.get_webview_window("main") { + main_window.theme().unwrap_or(Theme::Dark) + } else { + Theme::Dark + } +} + +pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) { + let tray = app.state::(); + let theme = get_current_theme(app); + + let icon_path = match (theme, &icon) { + // Dark theme uses regular icons (lighter colored for visibility) + (Theme::Dark, TrayIconState::Idle) => "resources/tray_idle.png", + (Theme::Dark, TrayIconState::Recording) => "resources/tray_recording.png", + (Theme::Dark, TrayIconState::Transcribing) => "resources/tray_transcribing.png", + // Light theme uses dark icons (darker colored for visibility) + (Theme::Light, TrayIconState::Idle) => "resources/tray_idle_dark.png", + (Theme::Light, TrayIconState::Recording) => "resources/tray_recording_dark.png", + (Theme::Light, TrayIconState::Transcribing) => "resources/tray_transcribing_dark.png", + // Fallback for any other theme variants + (_, TrayIconState::Idle) => "resources/tray_idle.png", + (_, TrayIconState::Recording) => "resources/tray_recording.png", + (_, TrayIconState::Transcribing) => "resources/tray_transcribing.png", + }; + + let _ = tray.set_icon(Some( + Image::from_path( + app.path() + .resolve(icon_path, tauri::path::BaseDirectory::Resource) + .expect("failed to resolve"), + ) + .expect("failed to set icon"), + )); + + // Update menu based on state + update_tray_menu(app, &icon); +} + +pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState) { + // Platform-specific accelerators + #[cfg(target_os = "macos")] + let (settings_accelerator, quit_accelerator) = (Some("Cmd+,"), Some("Cmd+Q")); + #[cfg(not(target_os = "macos"))] + let (settings_accelerator, quit_accelerator) = (Some("Ctrl+,"), Some("Ctrl+Q")); + + // Create common menu items + let version_label = format!("Handy v{}", env!("CARGO_PKG_VERSION")); + let version_i = MenuItem::with_id(app, "version", &version_label, false, None::<&str>) + .expect("failed to create version item"); + let settings_i = MenuItem::with_id(app, "settings", "Settings...", true, settings_accelerator) + .expect("failed to create settings item"); + let check_updates_i = MenuItem::with_id( + app, + "check_updates", + "Check for Updates...", + true, + None::<&str>, + ) + .expect("failed to create check updates item"); + let quit_i = MenuItem::with_id(app, "quit", "Quit", true, quit_accelerator) + .expect("failed to create quit item"); + let separator = || PredefinedMenuItem::separator(app).expect("failed to create separator"); + + let menu = match state { + TrayIconState::Recording | TrayIconState::Transcribing => { + let cancel_i = MenuItem::with_id(app, "cancel", "Cancel", true, None::<&str>) + .expect("failed to create cancel item"); + Menu::with_items( + app, + &[ + &version_i, + &separator(), + &cancel_i, + &separator(), + &settings_i, + &check_updates_i, + &separator(), + &quit_i, + ], + ) + .expect("failed to create menu") + } + TrayIconState::Idle => Menu::with_items( + app, + &[ + &version_i, + &separator(), + &settings_i, + &check_updates_i, + &separator(), + &quit_i, + ], + ) + .expect("failed to create menu"), + }; + + let tray = app.state::(); + let _ = tray.set_menu(Some(menu)); + let _ = tray.set_icon_as_template(true); +} diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index e22d438..4f1d177 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -1,136 +1,18 @@ -use crate::settings; +use crate::actions::ACTION_MAP; +use crate::managers::audio::AudioRecordingManager; +use crate::ManagedToggleState; +use std::sync::Arc; +use tauri::{AppHandle, Manager}; -use enigo::Enigo; -use enigo::Key; -use enigo::Keyboard; -use enigo::Settings; - -use cpal::traits::{DeviceTrait, HostTrait}; -use log::debug; -use rodio::OutputStreamBuilder; -use std::fs::File; -use std::io::BufReader; -use std::thread; -use tauri::image::Image; -use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; -use tauri::tray::TrayIcon; -use tauri::{AppHandle, Emitter, Manager, Theme, WebviewWindowBuilder}; -use tauri_plugin_clipboard_manager::ClipboardExt; - -/// Sends a paste command (Cmd+V or Ctrl+V) using platform-specific virtual key codes. -/// This ensures the paste works regardless of keyboard layout (e.g., Russian, AZERTY, DVORAK). -fn send_paste() -> Result<(), String> { - // Platform-specific key definitions - #[cfg(target_os = "macos")] - let (modifier_key, v_key_code) = (Key::Meta, Key::Other(9)); - #[cfg(target_os = "windows")] - let (modifier_key, v_key_code) = (Key::Control, Key::Other(0x56)); // VK_V - #[cfg(target_os = "linux")] - let (modifier_key, v_key_code) = (Key::Control, Key::Unicode('v')); - - let mut enigo = Enigo::new(&Settings::default()) - .map_err(|e| format!("Failed to initialize Enigo: {}", e))?; - - // Press modifier + V - enigo - .key(modifier_key, enigo::Direction::Press) - .map_err(|e| format!("Failed to press modifier key: {}", e))?; - enigo - .key(v_key_code, enigo::Direction::Press) - .map_err(|e| format!("Failed to press V key: {}", e))?; - - // Release V + modifier (reverse order) - enigo - .key(v_key_code, enigo::Direction::Release) - .map_err(|e| format!("Failed to release V key: {}", e))?; - enigo - .key(modifier_key, enigo::Direction::Release) - .map_err(|e| format!("Failed to release modifier key: {}", e))?; - - Ok(()) -} - -pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { - let clipboard = app_handle.clipboard(); - - // get the current clipboard content - let clipboard_content = clipboard.read_text().unwrap_or_default(); - - clipboard - .write_text(&text) - .map_err(|e| format!("Failed to write to clipboard: {}", e))?; - - // small delay to ensure the clipboard content has been written to - std::thread::sleep(std::time::Duration::from_millis(50)); - - send_paste()?; - - std::thread::sleep(std::time::Duration::from_millis(50)); - - // restore the clipboard - clipboard - .write_text(&clipboard_content) - .map_err(|e| format!("Failed to restore clipboard: {}", e))?; - - Ok(()) -} - -#[derive(Clone, Debug, PartialEq)] -pub enum TrayIconState { - Idle, - Recording, - Transcribing, -} - -/// Gets the current system theme, defaulting to Dark if unavailable -fn get_current_theme(app: &AppHandle) -> Theme { - if let Some(main_window) = app.get_webview_window("main") { - main_window.theme().unwrap_or(Theme::Dark) - } else { - Theme::Dark - } -} - -pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) { - let tray = app.state::(); - let theme = get_current_theme(app); - - let icon_path = match (theme, &icon) { - // Dark theme uses regular icons (lighter colored for visibility) - (Theme::Dark, TrayIconState::Idle) => "resources/tray_idle.png", - (Theme::Dark, TrayIconState::Recording) => "resources/tray_recording.png", - (Theme::Dark, TrayIconState::Transcribing) => "resources/tray_transcribing.png", - // Light theme uses dark icons (darker colored for visibility) - (Theme::Light, TrayIconState::Idle) => "resources/tray_idle_dark.png", - (Theme::Light, TrayIconState::Recording) => "resources/tray_recording_dark.png", - (Theme::Light, TrayIconState::Transcribing) => "resources/tray_transcribing_dark.png", - // Fallback for any other theme variants - (_, TrayIconState::Idle) => "resources/tray_idle.png", - (_, TrayIconState::Recording) => "resources/tray_recording.png", - (_, TrayIconState::Transcribing) => "resources/tray_transcribing.png", - }; - - let _ = tray.set_icon(Some( - Image::from_path( - app.path() - .resolve(icon_path, tauri::path::BaseDirectory::Resource) - .expect("failed to resolve"), - ) - .expect("failed to set icon"), - )); - - // Update menu based on state - update_tray_menu(app, &icon); -} +// Re-export all utility modules for easy access +pub use crate::audio_feedback::*; +pub use crate::clipboard::*; +pub use crate::overlay::*; +pub use crate::tray::*; /// Centralized cancellation function that can be called from anywhere in the app. /// Handles cancelling both recording and transcription operations and updates UI state. pub fn cancel_current_operation(app: &AppHandle) { - use crate::actions::ACTION_MAP; - use crate::managers::audio::AudioRecordingManager; - use crate::ManagedToggleState; - use std::sync::Arc; - println!("Initiating operation cancellation..."); // First, reset all shortcut toggle states and call stop actions @@ -167,303 +49,7 @@ pub fn cancel_current_operation(app: &AppHandle) { audio_manager.cancel_recording(); // Update tray icon and menu to idle state - change_tray_icon(app, TrayIconState::Idle); + change_tray_icon(app, crate::tray::TrayIconState::Idle); println!("Operation cancellation completed - returned to idle state"); } - -pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState) { - // Platform-specific accelerators - #[cfg(target_os = "macos")] - let (settings_accelerator, quit_accelerator) = (Some("Cmd+,"), Some("Cmd+Q")); - #[cfg(not(target_os = "macos"))] - let (settings_accelerator, quit_accelerator) = (Some("Ctrl+,"), Some("Ctrl+Q")); - - // Create common menu items - let version_label = format!("Handy v{}", env!("CARGO_PKG_VERSION")); - let version_i = MenuItem::with_id(app, "version", &version_label, false, None::<&str>) - .expect("failed to create version item"); - let settings_i = MenuItem::with_id(app, "settings", "Settings...", true, settings_accelerator) - .expect("failed to create settings item"); - let check_updates_i = MenuItem::with_id( - app, - "check_updates", - "Check for Updates...", - true, - None::<&str>, - ) - .expect("failed to create check updates item"); - let quit_i = MenuItem::with_id(app, "quit", "Quit", true, quit_accelerator) - .expect("failed to create quit item"); - let separator = || PredefinedMenuItem::separator(app).expect("failed to create separator"); - - let menu = match state { - TrayIconState::Recording | TrayIconState::Transcribing => { - let cancel_i = MenuItem::with_id(app, "cancel", "Cancel", true, None::<&str>) - .expect("failed to create cancel item"); - Menu::with_items( - app, - &[ - &version_i, - &separator(), - &cancel_i, - &separator(), - &settings_i, - &check_updates_i, - &separator(), - &quit_i, - ], - ) - .expect("failed to create menu") - } - TrayIconState::Idle => Menu::with_items( - app, - &[ - &version_i, - &separator(), - &settings_i, - &check_updates_i, - &separator(), - &quit_i, - ], - ) - .expect("failed to create menu"), - }; - - let tray = app.state::(); - let _ = tray.set_menu(Some(menu)); - let _ = tray.set_icon_as_template(true); -} - -/// Plays an audio resource from the resources directory. -/// Checks if audio feedback is enabled in settings before playing. -pub fn play_sound(app: &AppHandle, resource_path: &str) { - // Check if audio feedback is enabled - let settings = settings::get_settings(app); - if !settings.audio_feedback { - return; - } - - let app_handle = app.clone(); - let resource_path = resource_path.to_string(); - - // Spawn a new thread to play the audio without blocking the main thread - thread::spawn(move || { - // Get the path to the audio file in resources - let audio_path = match app_handle - .path() - .resolve(&resource_path, tauri::path::BaseDirectory::Resource) - { - Ok(path) => path, - Err(e) => { - eprintln!( - "Failed to resolve audio file path '{}': {}", - resource_path, e - ); - return; - } - }; - - // Get the selected output device from settings - let settings = settings::get_settings(&app_handle); - let selected_device = settings.selected_output_device.clone(); - - // Try to play the audio file - if let Err(e) = play_audio_file(&audio_path, selected_device) { - eprintln!("Failed to play sound '{}': {}", resource_path, e); - } - }); -} - -/// Convenience function to play the recording start sound -pub fn play_recording_start_sound(app: &AppHandle) { - play_sound(app, "resources/rec_start.wav"); -} - -/// Convenience function to play the recording stop sound -pub fn play_recording_stop_sound(app: &AppHandle) { - play_sound(app, "resources/rec_stop.wav"); -} - -fn play_audio_file( - path: &std::path::Path, - selected_device: Option, -) -> Result<(), Box> { - let stream_builder = if let Some(device_name) = selected_device { - if device_name == "Default" { - println!("Using default device"); - // Use default device - OutputStreamBuilder::from_default_device()? - } else { - // Try to find the device by name - let host = cpal::default_host(); - let devices = host.output_devices()?; - - let mut found_device = None; - for device in devices { - if device.name()? == device_name { - found_device = Some(device); - break; - } - } - - match found_device { - Some(device) => OutputStreamBuilder::from_device(device)?, - None => { - eprintln!("Device '{}' not found, using default device", device_name); - OutputStreamBuilder::from_default_device()? - } - } - } - } else { - println!("Using default device"); - // Use default device - OutputStreamBuilder::from_default_device()? - }; - - let stream_handle = stream_builder.open_stream()?; - let mixer = stream_handle.mixer(); - - // Load the audio file - let file = File::open(path)?; - let buf_reader = BufReader::new(file); - - let sink = rodio::play(mixer, buf_reader)?; - sink.sleep_until_end(); - - Ok(()) -} - -/* ──────────────────────────────────────────────────────────────── */ -/* OVERLAY MANAGEMENT */ -/* ──────────────────────────────────────────────────────────────── */ - -const OVERLAY_WIDTH: f64 = 172.0; -const OVERLAY_HEIGHT: f64 = 36.0; -const OVERLAY_OFFSET: f64 = 46.0; - -fn calculate_overlay_position(app_handle: &AppHandle) -> Option<(f64, f64)> { - if let Ok(monitors) = app_handle.primary_monitor() { - if let Some(monitor) = monitors { - let work_area = monitor.work_area(); - let scale = monitor.scale_factor(); - let work_area_width = work_area.size.width as f64 / scale; - let work_area_height = work_area.size.height as f64 / scale; - let work_area_x = work_area.position.x as f64 / scale; - let work_area_y = work_area.position.y as f64 / scale; - - let settings = settings::get_settings(app_handle); - let overlay_position = settings.overlay_position.as_str(); - - let x = work_area_x + (work_area_width - OVERLAY_WIDTH) / 2.0; - let y = match overlay_position { - "top" => work_area_y + OVERLAY_OFFSET, - "bottom" => work_area_y + work_area_height - OVERLAY_HEIGHT - OVERLAY_OFFSET, - _ => work_area_y + work_area_height - OVERLAY_HEIGHT - OVERLAY_OFFSET, - }; - - return Some((x, y)); - } - } - None -} - -/// Creates the recording overlay window and keeps it hidden by default -pub fn create_recording_overlay(app_handle: &AppHandle) { - if let Some((x, y)) = calculate_overlay_position(app_handle) { - match WebviewWindowBuilder::new( - app_handle, - "recording_overlay", - tauri::WebviewUrl::App("src/overlay/index.html".into()), - ) - .title("Recording") - .position(x, y) - .resizable(false) - .inner_size(OVERLAY_WIDTH, OVERLAY_HEIGHT) - .shadow(false) - .maximizable(false) - .minimizable(false) - .closable(false) - .accept_first_mouse(true) - .decorations(false) - .always_on_top(true) - .skip_taskbar(true) - .transparent(true) - .focused(false) - .visible(false) - .build() - { - Ok(_window) => { - debug!("Recording overlay window created successfully (hidden)"); - } - Err(e) => { - debug!("Failed to create recording overlay window: {}", e); - } - } - } -} - -/// Shows the recording overlay window with fade-in animation -pub fn show_recording_overlay(app_handle: &AppHandle) { - // Check if overlay should be shown based on position setting - let settings = settings::get_settings(app_handle); - if settings.overlay_position == "none" { - return; - } - - if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { - let _ = overlay_window.show(); - // Emit event to trigger fade-in animation with recording state - let _ = overlay_window.emit("show-overlay", "recording"); - } -} - -/// Shows the transcribing overlay window -pub fn show_transcribing_overlay(app_handle: &AppHandle) { - // Check if overlay should be shown based on position setting - let settings = settings::get_settings(app_handle); - if settings.overlay_position == "none" { - return; - } - - if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { - let _ = overlay_window.show(); - // Emit event to switch to transcribing state - let _ = overlay_window.emit("show-overlay", "transcribing"); - } -} - -/// Updates the overlay window position based on current settings -pub fn update_overlay_position(app_handle: &AppHandle) { - if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { - if let Some((x, y)) = calculate_overlay_position(app_handle) { - let _ = overlay_window.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); - } - } -} - -/// Hides the recording overlay window with fade-out animation -pub fn hide_recording_overlay(app_handle: &AppHandle) { - // Always hide the overlay regardless of settings - if setting was changed while recording, - // we still want to hide it properly - if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { - // Emit event to trigger fade-out animation - let _ = overlay_window.emit("hide-overlay", ()); - // Hide the window after a short delay to allow animation to complete - let window_clone = overlay_window.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(300)); - let _ = window_clone.hide(); - }); - } -} - -pub fn emit_levels(app_handle: &AppHandle, levels: &Vec) { - // emit levels to main app - let _ = app_handle.emit("mic-level", levels); - - // also emit to the recording overlay if it's open - if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { - let _ = overlay_window.emit("mic-level", levels); - } -} diff --git a/src/components/icons/CancelIcon.tsx b/src/components/icons/CancelIcon.tsx new file mode 100644 index 0000000..f468d62 --- /dev/null +++ b/src/components/icons/CancelIcon.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +interface CancelIconProps { + width?: number; + height?: number; + color?: string; + className?: string; +} + +const CancelIcon: React.FC = ({ + width = 24, + height = 24, + color = "#FAA2CA", + className = "", +}) => { + return ( + + + + + + + + ); +}; + +export default CancelIcon; diff --git a/src/components/icons/MicrophoneIcon.tsx b/src/components/icons/MicrophoneIcon.tsx new file mode 100644 index 0000000..0bb9e0d --- /dev/null +++ b/src/components/icons/MicrophoneIcon.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +interface MicrophoneIconProps { + width?: number; + height?: number; + color?: string; + className?: string; +} + +const MicrophoneIcon: React.FC = ({ + width = 24, + height = 24, + color = "#FAA2CA", + className = "", +}) => { + return ( + + + + + ); +}; + +export default MicrophoneIcon; diff --git a/src/components/icons/TranscriptionIcon.tsx b/src/components/icons/TranscriptionIcon.tsx new file mode 100644 index 0000000..08fa0ac --- /dev/null +++ b/src/components/icons/TranscriptionIcon.tsx @@ -0,0 +1,53 @@ +import React from "react"; + +interface TranscriptionIconProps { + width?: number; + height?: number; + color?: string; + className?: string; +} + +const TranscriptionIcon: React.FC = ({ + width = 24, + height = 24, + color = "#FAA2CA", + className = "", +}) => { + return ( + + + + + + + + + ); +}; + +export default TranscriptionIcon; diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts new file mode 100644 index 0000000..ee5fec9 --- /dev/null +++ b/src/components/icons/index.ts @@ -0,0 +1,3 @@ +export { default as MicrophoneIcon } from './MicrophoneIcon'; +export { default as TranscriptionIcon } from './TranscriptionIcon'; +export { default as CancelIcon } from './CancelIcon'; diff --git a/src/components/settings/HandyShortcut.tsx b/src/components/settings/HandyShortcut.tsx index 2501ab0..7d9e820 100644 --- a/src/components/settings/HandyShortcut.tsx +++ b/src/components/settings/HandyShortcut.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState, useRef } from "react"; -import { BindingResponseSchema, ShortcutBindingsMap } from "../../lib/types"; import { type } from "@tauri-apps/plugin-os"; import { getKeyName, diff --git a/src/components/settings/ShowOverlay.tsx b/src/components/settings/ShowOverlay.tsx index 9812abf..3810852 100644 --- a/src/components/settings/ShowOverlay.tsx +++ b/src/components/settings/ShowOverlay.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { useSettings } from "../../hooks/useSettings"; +import type { OverlayPosition } from "../../lib/types"; interface ShowOverlayProps { descriptionMode?: "inline" | "tooltip"; @@ -20,7 +21,7 @@ export const ShowOverlay: React.FC = ({ }) => { const { getSetting, updateSetting, isUpdating } = useSettings(); - const selectedPosition = getSetting("overlay_position") || "bottom"; + const selectedPosition = (getSetting("overlay_position") || "bottom") as OverlayPosition; return ( = ({ updateSetting("overlay_position", value)} + onSelect={(value) => updateSetting("overlay_position", value as OverlayPosition)} disabled={isUpdating("overlay_position")} /> diff --git a/src/lib/types.ts b/src/lib/types.ts index 4ecd42e..dfec7b3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -19,6 +19,9 @@ export const AudioDeviceSchema = z.object({ is_default: z.boolean(), }); +export const OverlayPositionSchema = z.enum(["none", "top", "bottom"]); +export type OverlayPosition = z.infer; + export const SettingsSchema = z.object({ bindings: ShortcutBindingsMapSchema, push_to_talk: z.boolean(), @@ -29,7 +32,7 @@ export const SettingsSchema = z.object({ selected_output_device: z.string().nullable().optional(), translate_to_english: z.boolean(), selected_language: z.string(), - overlay_position: z.string(), + overlay_position: OverlayPositionSchema, debug_mode: z.boolean(), }); diff --git a/src/overlay/RecordingOverlay.tsx b/src/overlay/RecordingOverlay.tsx index 39faefa..16fc4b9 100644 --- a/src/overlay/RecordingOverlay.tsx +++ b/src/overlay/RecordingOverlay.tsx @@ -1,6 +1,11 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import React, { useEffect, useRef, useState } from "react"; +import { + MicrophoneIcon, + TranscriptionIcon, + CancelIcon, +} from "../components/icons"; import "./RecordingOverlay.css"; type OverlayState = "recording" | "transcribing"; @@ -53,34 +58,15 @@ const RecordingOverlay: React.FC = () => { const getIcon = () => { if (state === "recording") { - return ( - - - - - - ); + return ; } else { - return ( - - - - - - - - - - ); + return ; } }; - return (
-
- {getIcon()} -
+
{getIcon()}
{state === "recording" && ( @@ -111,7 +97,7 @@ const RecordingOverlay: React.FC = () => { invoke("cancel_operation"); }} > - +
)}
From b286a4118f8ade64a6c37e4ff7676f3f723ec581 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Thu, 7 Aug 2025 17:03:25 -0700 Subject: [PATCH 6/8] tweak language slightly. --- src/components/settings/ShowOverlay.tsx | 17 ++++++----- src/components/ui/Dropdown.tsx | 39 ++++++++++++++++++++----- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/components/settings/ShowOverlay.tsx b/src/components/settings/ShowOverlay.tsx index 3810852..e177e6d 100644 --- a/src/components/settings/ShowOverlay.tsx +++ b/src/components/settings/ShowOverlay.tsx @@ -10,22 +10,23 @@ interface ShowOverlayProps { } const overlayOptions = [ - { value: "none", label: "Do not show" }, - { value: "bottom", label: "On bottom" }, - { value: "top", label: "On top" } + { value: "none", label: "None" }, + { value: "bottom", label: "Bottom" }, + { value: "top", label: "Top" }, ]; export const ShowOverlay: React.FC = ({ descriptionMode = "tooltip", - grouped = false + grouped = false, }) => { const { getSetting, updateSetting, isUpdating } = useSettings(); - const selectedPosition = (getSetting("overlay_position") || "bottom") as OverlayPosition; + const selectedPosition = (getSetting("overlay_position") || + "bottom") as OverlayPosition; return ( = ({ updateSetting("overlay_position", value as OverlayPosition)} + onSelect={(value) => + updateSetting("overlay_position", value as OverlayPosition) + } disabled={isUpdating("overlay_position")} /> diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx index 857d69e..5ffb010 100644 --- a/src/components/ui/Dropdown.tsx +++ b/src/components/ui/Dropdown.tsx @@ -7,6 +7,7 @@ export interface DropdownOption { interface DropdownProps { options: DropdownOption[]; + className?: string; selectedValue: string | null; onSelect: (value: string) => void; placeholder?: string; @@ -18,6 +19,7 @@ export const Dropdown: React.FC = ({ options, selectedValue, onSelect, + className = "", placeholder = "Select an option...", disabled = false, onRefresh, @@ -27,7 +29,10 @@ export const Dropdown: React.FC = ({ useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { setIsOpen(false); } }; @@ -35,7 +40,9 @@ export const Dropdown: React.FC = ({ return () => document.removeEventListener("mousedown", handleClickOutside); }, []); - const selectedOption = options.find(option => option.value === selectedValue); + const selectedOption = options.find( + (option) => option.value === selectedValue, + ); const handleSelect = (value: string) => { onSelect(value); @@ -49,31 +56,47 @@ export const Dropdown: React.FC = ({ }; return ( -
+
{isOpen && !disabled && (
{options.length === 0 ? ( -
No options found
+
+ No options found +
) : ( options.map((option) => (