* wip ui

* pretty much fully new settings page

* proper dark/light mode

* slight style tweak for sidebar

* add about
This commit is contained in:
CJ Pais 2025-09-05 12:56:55 -07:00 committed by GitHub
parent cd6102c69a
commit d6684ccb21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 783 additions and 522 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -23,12 +23,14 @@
"@tauri-apps/plugin-stronghold": "~2", "@tauri-apps/plugin-stronghold": "~2",
"@tauri-apps/plugin-updater": "~2", "@tauri-apps/plugin-updater": "~2",
"@tauri-apps/plugin-upload": "~2", "@tauri-apps/plugin-upload": "~2",
"lucide-react": "^0.542.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwindcss": "^4.0.2", "tailwindcss": "^4.0.2",
"tauri-plugin-macos-permissions-api": "^2.0.4", "tauri-plugin-macos-permissions-api": "^2.0.4",
"zod": "^3.24.4" "zod": "^3.24.4",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",

View file

@ -14,10 +14,10 @@
"windows": [ "windows": [
{ {
"title": "Handy", "title": "Handy",
"width": 540, "width": 680,
"height": 740, "height": 510,
"minWidth": 540, "minWidth": 680,
"minHeight": 740, "minHeight": 510,
"resizable": true, "resizable": true,
"maximizable": false "maximizable": false
} }

View file

@ -6,15 +6,50 @@ import AccessibilityPermissions from "./components/AccessibilityPermissions";
import Footer from "./components/footer"; import Footer from "./components/footer";
import HandyTextLogo from "./components/icons/HandyTextLogo"; import HandyTextLogo from "./components/icons/HandyTextLogo";
import Onboarding from "./components/onboarding"; import Onboarding from "./components/onboarding";
import { Settings } from "./components/settings/Settings"; import { Sidebar, SidebarSection, SECTIONS_CONFIG } from "./components/Sidebar";
import { useSettings } from "./hooks/useSettings";
const renderSettingsContent = (section: SidebarSection) => {
const ActiveComponent =
SECTIONS_CONFIG[section]?.component || SECTIONS_CONFIG.general.component;
return <ActiveComponent />;
};
function App() { function App() {
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null); const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
const [currentSection, setCurrentSection] =
useState<SidebarSection>("general");
const { settings, updateSetting } = useSettings();
useEffect(() => { useEffect(() => {
checkOnboardingStatus(); checkOnboardingStatus();
}, []); }, []);
// Handle keyboard shortcuts for debug mode toggle
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Check for Ctrl+Shift+D (Windows/Linux) or Cmd+Shift+D (macOS)
const isDebugShortcut =
event.shiftKey &&
event.key.toLowerCase() === "d" &&
(event.ctrlKey || event.metaKey);
if (isDebugShortcut) {
event.preventDefault();
const currentDebugMode = settings?.debug_mode ?? false;
updateSetting("debug_mode", !currentDebugMode);
}
};
// Add event listener when component mounts
document.addEventListener("keydown", handleKeyDown);
// Cleanup event listener when component unmounts
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [settings?.debug_mode, updateSetting]);
const checkOnboardingStatus = async () => { const checkOnboardingStatus = async () => {
try { try {
// Always check if they have any models available // Always check if they have any models available
@ -33,23 +68,36 @@ function App() {
if (showOnboarding) { if (showOnboarding) {
return ( return (
<div className="min-h-screen flex flex-col w-full"> <div className="h-screen flex flex-col">
<div className="flex flex-col items-center p-4 gap-4 flex-1"> <div className="flex-1 flex flex-col items-center justify-center p-4 gap-4">
<HandyTextLogo width={200} /> <HandyTextLogo width={200} />
<Onboarding onModelSelected={handleModelSelected} /> <Onboarding onModelSelected={handleModelSelected} />
</div> </div>
<Footer />
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen flex flex-col w-full"> <div className="h-screen flex flex-col">
<Toaster /> <Toaster />
<div className="flex flex-col items-center p-4 gap-4 flex-1"> {/* Main content area that takes remaining space */}
<HandyTextLogo width={200} /> <div className="flex-1 flex overflow-hidden">
<AccessibilityPermissions /> <Sidebar
<Settings /> activeSection={currentSection}
onSectionChange={setCurrentSection}
/>
{/* Scrollable content area */}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col items-center p-4 gap-4">
<AccessibilityPermissions />
{renderSettingsContent(currentSection)}
</div>
</div>
</div>
</div> </div>
{/* Fixed footer at bottom */}
<Footer /> <Footer />
</div> </div>
); );

105
src/components/Sidebar.tsx Normal file
View file

@ -0,0 +1,105 @@
import React from "react";
import { Cog, FlaskConical, History, Info } from "lucide-react";
import HandyTextLogo from "./icons/HandyTextLogo";
import HandyHand from "./icons/HandyHand";
import { useSettings } from "../hooks/useSettings";
import {
GeneralSettings,
AdvancedSettings,
HistorySettings,
DebugSettings,
AboutSettings,
} from "./settings";
export type SidebarSection = keyof typeof SECTIONS_CONFIG;
interface IconProps {
width?: number | string;
height?: number | string;
size?: number | string;
className?: string;
[key: string]: any;
}
interface SectionConfig {
label: string;
icon: React.ComponentType<IconProps>;
component: React.ComponentType;
enabled: (settings: any) => boolean;
}
export const SECTIONS_CONFIG = {
general: {
label: "General",
icon: HandyHand,
component: GeneralSettings,
enabled: () => true,
},
advanced: {
label: "Advanced",
icon: Cog,
component: AdvancedSettings,
enabled: () => true,
},
// history: {
// label: "History",
// icon: History,
// component: HistorySettings,
// enabled: () => true,
// },
debug: {
label: "Debug",
icon: FlaskConical,
component: DebugSettings,
enabled: (settings) => settings?.debug_mode ?? false,
},
about: {
label: "About",
icon: Info,
component: AboutSettings,
enabled: () => true,
},
} as const satisfies Record<string, SectionConfig>;
interface SidebarProps {
activeSection: SidebarSection;
onSectionChange: (section: SidebarSection) => void;
}
export const Sidebar: React.FC<SidebarProps> = ({
activeSection,
onSectionChange,
}) => {
const { settings } = useSettings();
const availableSections = Object.entries(SECTIONS_CONFIG)
.filter(([_, config]) => config.enabled(settings))
.map(([id, config]) => ({ id: id as SidebarSection, ...config }));
return (
<div className="flex flex-col w-40 h-full border-r border-mid-gray/20 items-center px-2">
<HandyTextLogo width={120} className="m-4" />
<div className="flex flex-col w-full items-center gap-1 pt-2 border-t border-mid-gray/20">
{availableSections.map((section) => {
const Icon = section.icon;
const isActive = activeSection === section.id;
return (
<div
key={section.id}
className={`flex gap-2 items-center p-2 w-full rounded-lg cursor-pointer transition-colors ${
isActive
? "bg-logo-primary/80"
: "hover:bg-mid-gray/20 hover:opacity-100 opacity-85"
}`}
onClick={() => onSectionChange(section.id)}
>
<Icon width={24} height={24} />
<p className="text-sm font-medium">{section.label}</p>
</div>
);
})}
</div>
</div>
);
};

View file

@ -22,7 +22,7 @@ const Footer: React.FC = () => {
}, []); }, []);
return ( return (
<div className="w-full border-t border-mid-gray/20 mt-4 pt-3"> <div className="w-full border-t border-mid-gray/20 pt-3">
<div className="flex justify-between items-center text-xs px-4 pb-3 text-text/60"> <div className="flex justify-between items-center text-xs px-4 pb-3 text-text/60">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<ModelSelector /> <ModelSelector />

File diff suppressed because one or more lines are too long

View file

@ -3,14 +3,17 @@ import React from "react";
const HandyTextLogo = ({ const HandyTextLogo = ({
width, width,
height, height,
className,
}: { }: {
width?: number; width?: number;
height?: number; height?: number;
className?: string;
}) => { }) => {
return ( return (
<svg <svg
width={width} width={width}
height={height} height={height}
className={className}
viewBox="0 0 930 328" viewBox="0 0 930 328"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View file

@ -0,0 +1,70 @@
import React, { useState, useEffect } from "react";
import { getVersion } from "@tauri-apps/api/app";
import { openUrl } from "@tauri-apps/plugin-opener";
import { SettingsGroup } from "../ui/SettingsGroup";
import { SettingContainer } from "../ui/SettingContainer";
import { Button } from "../ui/Button";
export const AboutSettings: React.FC = () => {
const [version, setVersion] = useState("");
useEffect(() => {
const fetchVersion = async () => {
try {
const appVersion = await getVersion();
setVersion(appVersion);
} catch (error) {
console.error("Failed to get app version:", error);
setVersion("0.1.2");
}
};
fetchVersion();
}, []);
const handleDonateClick = async () => {
try {
await openUrl("https://handy.computer/donate");
} catch (error) {
console.error("Failed to open donate link:", error);
}
};
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="About">
<SettingContainer
title="Version"
description="Current version of Handy"
grouped={true}
>
<span className="text-sm font-mono">v{version}</span>
</SettingContainer>
<SettingContainer
title="Support Development"
description="Help us continue building Handy"
grouped={true}
>
<Button variant="primary" size="md" onClick={handleDonateClick}>
Donate
</Button>
</SettingContainer>
</SettingsGroup>
<SettingsGroup title="Acknowledgments">
<SettingContainer
title="Whisper.cpp"
description="High-performance inference of OpenAI's Whisper automatic speech recognition model"
grouped={true}
layout="stacked"
>
<div className="text-sm text-mid-gray">
Handy uses Whisper.cpp for fast, local speech-to-text processing.
Thanks to the amazing work by Georgi Gerganov and contributors.
</div>
</SettingContainer>
</SettingsGroup>
</div>
);
};

View file

@ -0,0 +1,21 @@
import React from "react";
import { ShowOverlay } from "./ShowOverlay";
import { TranslateToEnglish } from "./TranslateToEnglish";
import { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout";
import { CustomWords } from "./CustomWords";
import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
import { SettingsGroup } from "../ui/SettingsGroup";
export const AdvancedSettings: React.FC = () => {
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="Advanced">
<ShowOverlay descriptionMode="tooltip" grouped={true} />
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
<ModelUnloadTimeoutSetting descriptionMode="tooltip" grouped={true} />
<CustomWords descriptionMode="tooltip" grouped />
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
</SettingsGroup>
</div>
);
};

View file

@ -15,15 +15,16 @@ export const AudioFeedback: React.FC<AudioFeedbackProps> = React.memo(({
const audioFeedbackEnabled = getSetting("audio_feedback") || false; const audioFeedbackEnabled = getSetting("audio_feedback") || false;
return ( return (
<ToggleSwitch <ToggleSwitch
checked={audioFeedbackEnabled} checked={audioFeedbackEnabled}
onChange={(enabled) => updateSetting("audio_feedback", enabled)} onChange={(enabled) => updateSetting("audio_feedback", enabled)}
isUpdating={isUpdating("audio_feedback")} isUpdating={isUpdating("audio_feedback")}
label="Audio Feedback" label="Audio Feedback"
description="Play sound when recording starts and stops" description="Play sound when recording starts and stops"
descriptionMode={descriptionMode} descriptionMode={descriptionMode}
grouped={grouped} grouped={grouped}
/> />
); );
}); },
);

View file

@ -0,0 +1,15 @@
import React from "react";
import { WordCorrectionThreshold } from "./debug/WordCorrectionThreshold";
import { AppDataDirectory } from "./AppDataDirectory";
import { SettingsGroup } from "../ui/SettingsGroup";
export const DebugSettings: React.FC = () => {
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="Debug">
<WordCorrectionThreshold descriptionMode="tooltip" grouped={true} />
<AppDataDirectory descriptionMode="tooltip" grouped={true} />
</SettingsGroup>
</div>
);
};

View file

@ -0,0 +1,31 @@
import React from "react";
import { MicrophoneSelector } from "./MicrophoneSelector";
import { LanguageSelector } from "./LanguageSelector";
import { HandyShortcut } from "./HandyShortcut";
import { SettingsGroup } from "../ui/SettingsGroup";
import { OutputDeviceSelector } from "./OutputDeviceSelector";
import { PushToTalk } from "./PushToTalk";
import { AudioFeedback } from "./AudioFeedback";
import { useSettings } from "../../hooks/useSettings";
export const GeneralSettings: React.FC = () => {
const { audioFeedbackEnabled } = useSettings();
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="General">
<HandyShortcut descriptionMode="tooltip" grouped={true} />
<LanguageSelector descriptionMode="tooltip" grouped={true} />
<PushToTalk descriptionMode="tooltip" grouped={true} />
</SettingsGroup>
<SettingsGroup title="Sound">
<MicrophoneSelector descriptionMode="tooltip" grouped={true} />
<AudioFeedback descriptionMode="tooltip" grouped={true} />
<OutputDeviceSelector
descriptionMode="tooltip"
grouped={true}
disabled={!audioFeedbackEnabled}
/>
</SettingsGroup>
</div>
);
};

View file

@ -0,0 +1,12 @@
import React from "react";
import { SettingsGroup } from "../ui/SettingsGroup";
export const HistorySettings: React.FC = () => {
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="History">
<></>
</SettingsGroup>
</div>
);
};

View file

@ -3,62 +3,79 @@ import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer"; import { SettingContainer } from "../ui/SettingContainer";
import { ResetButton } from "../ui/ResetButton"; import { ResetButton } from "../ui/ResetButton";
import { useSettings } from "../../hooks/useSettings"; import { useSettings } from "../../hooks/useSettings";
import { AudioDevice } from "../../lib/types";
interface OutputDeviceSelectorProps { interface OutputDeviceSelectorProps {
descriptionMode?: "inline" | "tooltip"; descriptionMode?: "inline" | "tooltip";
grouped?: boolean; grouped?: boolean;
disabled?: boolean;
} }
export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = React.memo(({ export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> =
descriptionMode = "tooltip", React.memo(
grouped = false, ({ descriptionMode = "tooltip", grouped = false, disabled = false }) => {
}) => { const {
const { getSetting,
getSetting, updateSetting,
updateSetting, resetSetting,
resetSetting, isUpdating,
isUpdating, isLoading,
isLoading, outputDevices,
outputDevices, refreshOutputDevices,
refreshOutputDevices, } = useSettings();
} = useSettings();
const selectedOutputDevice = getSetting("selected_output_device") === "default" ? "Default" : getSetting("selected_output_device") || "Default"; const selectedOutputDevice =
getSetting("selected_output_device") === "default"
? "Default"
: getSetting("selected_output_device") || "Default";
const handleOutputDeviceSelect = async (deviceName: string) => { const handleOutputDeviceSelect = async (deviceName: string) => {
await updateSetting("selected_output_device", deviceName); await updateSetting("selected_output_device", deviceName);
}; };
const handleReset = async () => { const handleReset = async () => {
await resetSetting("selected_output_device"); await resetSetting("selected_output_device");
}; };
const outputDeviceOptions = outputDevices.map(device => ({ const outputDeviceOptions = outputDevices.map((device: AudioDevice) => ({
value: device.name, value: device.name,
label: device.name label: device.name,
})); }));
return ( return (
<SettingContainer <SettingContainer
title="Output Device" title="Output Device"
description="Select your preferred audio output device for feedback sounds" description="Select your preferred audio output device for feedback sounds"
descriptionMode={descriptionMode} descriptionMode={descriptionMode}
grouped={grouped} grouped={grouped}
> disabled={disabled}
<div className="flex items-center space-x-1"> >
<Dropdown <div className="flex items-center space-x-1">
options={outputDeviceOptions} <Dropdown
selectedValue={selectedOutputDevice} options={outputDeviceOptions}
onSelect={handleOutputDeviceSelect} selectedValue={selectedOutputDevice}
placeholder={isLoading || outputDevices.length === 0 ? "Loading..." : "Select output device..."} onSelect={handleOutputDeviceSelect}
disabled={isUpdating("selected_output_device") || isLoading || outputDevices.length === 0} placeholder={
onRefresh={refreshOutputDevices} isLoading || outputDevices.length === 0
/> ? "Loading..."
<ResetButton : "Select output device..."
onClick={handleReset} }
disabled={isUpdating("selected_output_device") || isLoading} disabled={
/> disabled ||
</div> isUpdating("selected_output_device") ||
</SettingContainer> isLoading ||
outputDevices.length === 0
}
onRefresh={refreshOutputDevices}
/>
<ResetButton
onClick={handleReset}
disabled={
disabled || isUpdating("selected_output_device") || isLoading
}
/>
</div>
</SettingContainer>
);
},
); );
});

View file

@ -1,73 +0,0 @@
import React, { useEffect } from "react";
import { MicrophoneSelector } from "./MicrophoneSelector";
import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
import { PushToTalk } from "./PushToTalk";
import { AudioFeedback } from "./AudioFeedback";
import { OutputDeviceSelector } from "./OutputDeviceSelector";
import { ShowOverlay } from "./ShowOverlay";
import { HandyShortcut } from "./HandyShortcut";
import { TranslateToEnglish } from "./TranslateToEnglish";
import { LanguageSelector } from "./LanguageSelector";
import { CustomWords } from "./CustomWords";
import { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout";
import { SettingsGroup } from "../ui/SettingsGroup";
import { WordCorrectionThreshold } from "./debug/WordCorrectionThreshold";
import { AppDataDirectory } from "./AppDataDirectory";
import { useSettings } from "../../hooks/useSettings";
export const Settings: React.FC = () => {
const { settings, updateSetting } = useSettings();
// Handle keyboard shortcuts for debug mode toggle
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Check for Ctrl+Shift+D (Windows/Linux) or Cmd+Shift+D (macOS)
const isDebugShortcut =
event.shiftKey &&
event.key.toLowerCase() === "d" &&
(event.ctrlKey || event.metaKey);
if (isDebugShortcut) {
event.preventDefault();
const currentDebugMode = settings?.debug_mode ?? false;
updateSetting("debug_mode", !currentDebugMode);
}
};
// Add event listener when component mounts
document.addEventListener("keydown", handleKeyDown);
// Cleanup event listener when component unmounts
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [settings?.debug_mode, updateSetting]);
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup>
<HandyShortcut descriptionMode="tooltip" grouped={true} />
<MicrophoneSelector descriptionMode="tooltip" grouped={true} />
<LanguageSelector descriptionMode="tooltip" grouped={true} />
</SettingsGroup>
<SettingsGroup title="Advanced">
<PushToTalk descriptionMode="tooltip" grouped={true} />
<AudioFeedback descriptionMode="tooltip" grouped={true} />
<OutputDeviceSelector descriptionMode="tooltip" grouped={true} />
<ShowOverlay descriptionMode="tooltip" grouped={true} />
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
<ModelUnloadTimeoutSetting descriptionMode="tooltip" grouped={true} />
<CustomWords descriptionMode="tooltip" grouped />
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
</SettingsGroup>
{settings?.debug_mode && (
<SettingsGroup title="Debug">
<WordCorrectionThreshold descriptionMode="tooltip" grouped={true} />
<AppDataDirectory descriptionMode="tooltip" grouped={true} />
</SettingsGroup>
)}
</div>
);
};

View file

@ -1,4 +1,11 @@
export { Settings } from "./Settings"; // Settings section components
export { GeneralSettings } from "./GeneralSettings";
export { AdvancedSettings } from "./AdvancedSettings";
export { DebugSettings } from "./DebugSettings";
export { HistorySettings } from "./HistorySettings";
export { AboutSettings } from "./AboutSettings";
// Individual setting components
export { MicrophoneSelector } from "./MicrophoneSelector"; export { MicrophoneSelector } from "./MicrophoneSelector";
export { OutputDeviceSelector } from "./OutputDeviceSelector"; export { OutputDeviceSelector } from "./OutputDeviceSelector";
export { AlwaysOnMicrophone } from "./AlwaysOnMicrophone"; export { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";

View file

@ -17,7 +17,7 @@ export const Button: React.FC<ButtonProps> = ({
const variantClasses = { const variantClasses = {
primary: primary:
"text-white bg-background-ui hover:bg-background-ui/90 focus:ring-1 focus:ring-background-ui", "text-white bg-background-ui hover:bg-background-ui/80 focus:ring-1 focus:ring-background-ui",
secondary: "bg-mid-gray/10 hover:bg-background-ui/30 focus:outline-none", secondary: "bg-mid-gray/10 hover:bg-background-ui/30 focus:outline-none",
danger: danger:
"text-white bg-red-600 hover:bg-red-700 focus:ring-1 focus:ring-red-500", "text-white bg-red-600 hover:bg-red-700 focus:ring-1 focus:ring-red-500",

View file

@ -10,7 +10,11 @@ interface ResetButtonProps {
export const ResetButton: React.FC<ResetButtonProps> = React.memo( export const ResetButton: React.FC<ResetButtonProps> = React.memo(
({ onClick, disabled = false, className = "" }) => ( ({ onClick, disabled = false, className = "" }) => (
<button <button
className={`p-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:translate-y-[1px] rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150 text-text/80 ${className}`} className={`p-1 rounded fill-text border border-transparent transition-all duration-150 ${
disabled
? "opacity-50 cursor-not-allowed text-text/40"
: "hover:bg-logo-primary/30 active:bg-logo-primary/50 active:translate-y-[1px] hover:cursor-pointer hover:border-logo-primary text-text/80"
} ${className}`}
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
> >

View file

@ -7,6 +7,7 @@ interface SettingContainerProps {
descriptionMode?: "inline" | "tooltip"; descriptionMode?: "inline" | "tooltip";
grouped?: boolean; grouped?: boolean;
layout?: "horizontal" | "stacked"; layout?: "horizontal" | "stacked";
disabled?: boolean;
} }
export const SettingContainer: React.FC<SettingContainerProps> = ({ export const SettingContainer: React.FC<SettingContainerProps> = ({
@ -16,6 +17,7 @@ export const SettingContainer: React.FC<SettingContainerProps> = ({
descriptionMode = "tooltip", descriptionMode = "tooltip",
grouped = false, grouped = false,
layout = "horizontal", layout = "horizontal",
disabled = false,
}) => { }) => {
const [showTooltip, setShowTooltip] = useState(false); const [showTooltip, setShowTooltip] = useState(false);
const tooltipRef = useRef<HTMLDivElement>(null); const tooltipRef = useRef<HTMLDivElement>(null);
@ -51,7 +53,11 @@ export const SettingContainer: React.FC<SettingContainerProps> = ({
return ( return (
<div className={containerClasses}> <div className={containerClasses}>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<h3 className="text-sm font-medium">{title}</h3> <h3
className={`text-sm font-medium ${disabled ? "opacity-50" : ""}`}
>
{title}
</h3>
<div <div
ref={tooltipRef} ref={tooltipRef}
className="relative" className="relative"
@ -99,8 +105,12 @@ export const SettingContainer: React.FC<SettingContainerProps> = ({
return ( return (
<div className={containerClasses}> <div className={containerClasses}>
<div className="mb-2"> <div className="mb-2">
<h3 className="text-sm font-medium">{title}</h3> <h3 className={`text-sm font-medium ${disabled ? "opacity-50" : ""}`}>
<p className="text-sm">{description}</p> {title}
</h3>
<p className={`text-sm ${disabled ? "opacity-50" : ""}`}>
{description}
</p>
</div> </div>
<div className="w-full">{children}</div> <div className="w-full">{children}</div>
</div> </div>
@ -117,7 +127,11 @@ export const SettingContainer: React.FC<SettingContainerProps> = ({
<div className={horizontalContainerClasses}> <div className={horizontalContainerClasses}>
<div className="max-w-2/3"> <div className="max-w-2/3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-sm font-medium ">{title}</h3> <h3
className={`text-sm font-medium ${disabled ? "opacity-50" : ""}`}
>
{title}
</h3>
<div <div
ref={tooltipRef} ref={tooltipRef}
className="relative" className="relative"
@ -166,8 +180,12 @@ export const SettingContainer: React.FC<SettingContainerProps> = ({
return ( return (
<div className={horizontalContainerClasses}> <div className={horizontalContainerClasses}>
<div className="max-w-2/3"> <div className="max-w-2/3">
<h3 className="text-sm font-medium ">{title}</h3> <h3 className={`text-sm font-medium ${disabled ? "opacity-50" : ""}`}>
<p className="text-sm">{description}</p> {title}
</h3>
<p className={`text-sm ${disabled ? "opacity-50" : ""}`}>
{description}
</p>
</div> </div>
<div className="relative">{children}</div> <div className="relative">{children}</div>
</div> </div>

View file

@ -1,15 +1,7 @@
import { useState, useEffect, useCallback } from "react"; import { useEffect } from "react";
import { invoke } from "@tauri-apps/api/core"; import { useSettingsStore } from "../stores/settingsStore";
import { Settings, AudioDevice } from "../lib/types"; import { Settings, AudioDevice } from "../lib/types";
interface SettingsState {
settings: Settings | null;
isLoading: boolean;
isUpdating: Record<string, boolean>;
audioDevices: AudioDevice[];
outputDevices: AudioDevice[];
}
interface UseSettingsReturn { interface UseSettingsReturn {
// State // State
settings: Settings | null; settings: Settings | null;
@ -17,6 +9,7 @@ interface UseSettingsReturn {
isUpdating: (key: string) => boolean; isUpdating: (key: string) => boolean;
audioDevices: AudioDevice[]; audioDevices: AudioDevice[];
outputDevices: AudioDevice[]; outputDevices: AudioDevice[];
audioFeedbackEnabled: boolean;
// Actions // Actions
updateSetting: <K extends keyof Settings>( updateSetting: <K extends keyof Settings>(
@ -37,366 +30,29 @@ interface UseSettingsReturn {
} }
export const useSettings = (): UseSettingsReturn => { export const useSettings = (): UseSettingsReturn => {
const [state, setState] = useState<SettingsState>({ const store = useSettingsStore();
settings: null,
isLoading: true,
isUpdating: {},
audioDevices: [],
outputDevices: [],
});
// Load settings from store // Initialize on first mount
const loadSettings = useCallback(async () => {
try {
const { load } = await import("@tauri-apps/plugin-store");
const store = await load("settings_store.json", { autoSave: false });
const settings = (await store.get("settings")) as Settings;
// Load additional settings that come from invoke calls
const [microphoneMode, selectedMicrophone, selectedOutputDevice] =
await Promise.allSettled([
invoke("get_microphone_mode"),
invoke("get_selected_microphone"),
invoke("get_selected_output_device"),
]);
// Merge all settings
const mergedSettings: Settings = {
...settings,
always_on_microphone:
microphoneMode.status === "fulfilled"
? (microphoneMode.value as boolean)
: false,
selected_microphone:
selectedMicrophone.status === "fulfilled"
? (selectedMicrophone.value as string)
: "Default",
selected_output_device:
selectedOutputDevice.status === "fulfilled"
? (selectedOutputDevice.value as string)
: "Default",
};
setState((prev) => ({
...prev,
settings: mergedSettings,
isLoading: false,
}));
} catch (error) {
console.error("Failed to load settings:", error);
setState((prev) => ({
...prev,
isLoading: false,
}));
}
}, []);
// Load audio devices
const loadAudioDevices = useCallback(async () => {
try {
const devices: AudioDevice[] = await invoke("get_available_microphones");
// Always ensure "Default" is available as the first option
const devicesWithDefault = [
{ index: "default", name: "Default", is_default: true },
...devices.filter((d) => d.name !== "Default" && d.name !== "default"),
];
setState((prev) => ({
...prev,
audioDevices: devicesWithDefault,
}));
} catch (error) {
console.error("Failed to load audio devices:", error);
setState((prev) => ({
...prev,
audioDevices: [{ index: "default", name: "Default", is_default: true }],
}));
}
}, []);
// Load output devices
const loadOutputDevices = useCallback(async () => {
try {
const devices: AudioDevice[] = await invoke(
"get_available_output_devices",
);
// Always ensure "Default" is available as the first option
const devicesWithDefault = [
{ index: "default", name: "Default", is_default: true },
...devices.filter((d) => d.name !== "Default" && d.name !== "default"),
];
setState((prev) => ({
...prev,
outputDevices: devicesWithDefault,
}));
} catch (error) {
console.error("Failed to load output devices:", error);
setState((prev) => ({
...prev,
outputDevices: [
{ index: "default", name: "Default", is_default: true },
],
}));
}
}, []);
// Update a specific setting
const updateSetting = useCallback(
async <K extends keyof Settings>(key: K, value: Settings[K]) => {
const updateKey = String(key);
// Set updating state
setState((prev) => ({
...prev,
isUpdating: { ...prev.isUpdating, [updateKey]: true },
}));
// Store original value for rollback
const originalValue = state.settings?.[key];
try {
// Optimistic update
setState((prev) => ({
...prev,
settings: prev.settings ? { ...prev.settings, [key]: value } : null,
}));
// Invoke the appropriate backend method based on the setting
switch (key) {
case "always_on_microphone":
await invoke("update_microphone_mode", { alwaysOn: value });
break;
case "audio_feedback":
await invoke("change_audio_feedback_setting", { enabled: value });
break;
case "push_to_talk":
await invoke("change_ptt_setting", { enabled: value });
break;
case "selected_microphone":
// Map "Default" to "default" for backend compatibility
const micDeviceName = value === "Default" ? "default" : value;
await invoke("set_selected_microphone", {
deviceName: micDeviceName,
});
break;
case "selected_output_device":
// Map "Default" to "default" for backend compatibility
const outputDeviceName = value === "Default" ? "default" : value;
await invoke("set_selected_output_device", {
deviceName: outputDeviceName,
});
break;
case "translate_to_english":
await invoke("change_translate_to_english_setting", {
enabled: value,
});
break;
case "selected_language":
await invoke("change_selected_language_setting", {
language: value,
});
break;
case "overlay_position":
await invoke("change_overlay_position_setting", {
position: value,
});
break;
case "debug_mode":
await invoke("change_debug_mode_setting", { enabled: value });
break;
case "custom_words":
await invoke("update_custom_words", { words: value });
break;
case "word_correction_threshold":
await invoke("change_word_correction_threshold_setting", {
threshold: value,
});
break;
case "bindings":
// Handle bindings separately - they use their own invoke methods
break;
case "selected_model":
// Handle model selection if needed
break;
default:
console.warn(`No handler for setting: ${String(key)}`);
}
// console.log(`Setting ${String(key)} updated to:`, value);
} catch (error) {
console.error(`Failed to update setting ${String(key)}:`, error);
// Rollback on error
setState((prev) => ({
...prev,
settings: prev.settings
? { ...prev.settings, [key]: originalValue }
: null,
}));
} finally {
// Clear updating state
setState((prev) => ({
...prev,
isUpdating: { ...prev.isUpdating, [updateKey]: false },
}));
}
},
[state.settings],
);
// Reset a setting to its default value
const resetSetting = useCallback(
async (key: keyof Settings) => {
// Define default values
const defaults: Partial<Settings> = {
always_on_microphone: false,
audio_feedback: true,
push_to_talk: false,
selected_microphone: "Default",
selected_output_device: "Default",
translate_to_english: false,
selected_language: "auto",
overlay_position: "bottom",
debug_mode: false,
custom_words: [],
};
const defaultValue = defaults[key];
if (defaultValue !== undefined) {
await updateSetting(key, defaultValue as any);
}
},
[updateSetting],
);
// Convenience getter
const getSetting = useCallback(
<K extends keyof Settings>(key: K): Settings[K] | undefined => {
return state.settings?.[key];
},
[state.settings],
);
// Update a specific binding
const updateBinding = useCallback(
async (id: string, binding: string) => {
const updateKey = `binding_${id}`;
// Set updating state
setState((prev) => ({
...prev,
isUpdating: { ...prev.isUpdating, [updateKey]: true },
}));
// Store original binding for rollback
const originalBinding = state.settings?.bindings?.[id]?.current_binding;
try {
// Optimistic update
setState((prev) => ({
...prev,
settings: prev.settings
? {
...prev.settings,
bindings: {
...prev.settings.bindings,
[id]: {
...prev.settings.bindings[id],
current_binding: binding,
},
},
}
: null,
}));
await invoke("change_binding", { id, binding });
console.log(`Binding ${id} updated to: ${binding}`);
} catch (error) {
console.error(`Failed to update binding ${id}:`, error);
// Rollback on error
if (originalBinding) {
setState((prev) => ({
...prev,
settings: prev.settings
? {
...prev.settings,
bindings: {
...prev.settings.bindings,
[id]: {
...prev.settings.bindings[id],
current_binding: originalBinding,
},
},
}
: null,
}));
}
} finally {
// Clear updating state
setState((prev) => ({
...prev,
isUpdating: { ...prev.isUpdating, [updateKey]: false },
}));
}
},
[state.settings],
);
// Reset a specific binding
const resetBinding = useCallback(
async (id: string) => {
const updateKey = `binding_${id}`;
// Set updating state
setState((prev) => ({
...prev,
isUpdating: { ...prev.isUpdating, [updateKey]: true },
}));
try {
const result = await invoke("reset_binding", { id });
// Refresh settings to get the updated binding
await loadSettings();
console.log(`Binding ${id} reset to default`);
} catch (error) {
console.error(`Failed to reset binding ${id}:`, error);
} finally {
// Clear updating state
setState((prev) => ({
...prev,
isUpdating: { ...prev.isUpdating, [updateKey]: false },
}));
}
},
[loadSettings],
);
// Initialize
useEffect(() => { useEffect(() => {
loadSettings(); if (store.isLoading) {
loadAudioDevices(); store.initialize();
loadOutputDevices(); }
}, [loadSettings, loadAudioDevices, loadOutputDevices]); }, [store.initialize, store.isLoading]);
return { return {
settings: state.settings, settings: store.settings,
isLoading: state.isLoading, isLoading: store.isLoading,
isUpdating: (key: string) => state.isUpdating[key] || false, isUpdating: store.isUpdatingKey,
audioDevices: state.audioDevices, audioDevices: store.audioDevices,
outputDevices: state.outputDevices, outputDevices: store.outputDevices,
updateSetting, audioFeedbackEnabled: store.settings?.audio_feedback || false,
resetSetting, updateSetting: store.updateSetting,
refreshSettings: loadSettings, resetSetting: store.resetSetting,
refreshAudioDevices: loadAudioDevices, refreshSettings: store.refreshSettings,
refreshOutputDevices: loadOutputDevices, refreshAudioDevices: store.refreshAudioDevices,
updateBinding, refreshOutputDevices: store.refreshOutputDevices,
resetBinding, updateBinding: store.updateBinding,
getSetting, resetBinding: store.resetBinding,
getSetting: store.getSetting,
}; };
}; };

300
src/stores/settingsStore.ts Normal file
View file

@ -0,0 +1,300 @@
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { Settings, AudioDevice } from '../lib/types';
interface SettingsStore {
settings: Settings | null;
isLoading: boolean;
isUpdating: Record<string, boolean>;
audioDevices: AudioDevice[];
outputDevices: AudioDevice[];
// Actions
initialize: () => Promise<void>;
updateSetting: <K extends keyof Settings>(key: K, value: Settings[K]) => Promise<void>;
resetSetting: (key: keyof Settings) => Promise<void>;
refreshSettings: () => Promise<void>;
refreshAudioDevices: () => Promise<void>;
refreshOutputDevices: () => Promise<void>;
updateBinding: (id: string, binding: string) => Promise<void>;
resetBinding: (id: string) => Promise<void>;
getSetting: <K extends keyof Settings>(key: K) => Settings[K] | undefined;
isUpdatingKey: (key: string) => boolean;
// Internal state setters
setSettings: (settings: Settings | null) => void;
setLoading: (loading: boolean) => void;
setUpdating: (key: string, updating: boolean) => void;
setAudioDevices: (devices: AudioDevice[]) => void;
setOutputDevices: (devices: AudioDevice[]) => void;
}
const DEFAULT_SETTINGS: Partial<Settings> = {
always_on_microphone: false,
audio_feedback: true,
push_to_talk: false,
selected_microphone: "Default",
selected_output_device: "Default",
translate_to_english: false,
selected_language: "auto",
overlay_position: "bottom",
debug_mode: false,
custom_words: [],
};
const DEFAULT_AUDIO_DEVICE: AudioDevice = {
index: "default",
name: "Default",
is_default: true
};
export const useSettingsStore = create<SettingsStore>()(
subscribeWithSelector((set, get) => ({
settings: null,
isLoading: true,
isUpdating: {},
audioDevices: [],
outputDevices: [],
// Internal setters
setSettings: (settings) => set({ settings }),
setLoading: (isLoading) => set({ isLoading }),
setUpdating: (key, updating) =>
set((state) => ({
isUpdating: { ...state.isUpdating, [key]: updating }
})),
setAudioDevices: (audioDevices) => set({ audioDevices }),
setOutputDevices: (outputDevices) => set({ outputDevices }),
// Getters
getSetting: (key) => get().settings?.[key],
isUpdatingKey: (key) => get().isUpdating[key] || false,
// Load settings from store
refreshSettings: async () => {
try {
const { load } = await import("@tauri-apps/plugin-store");
const store = await load("settings_store.json", { autoSave: false });
const settings = (await store.get("settings")) as Settings;
// Load additional settings that come from invoke calls
const [microphoneMode, selectedMicrophone, selectedOutputDevice] =
await Promise.allSettled([
invoke("get_microphone_mode"),
invoke("get_selected_microphone"),
invoke("get_selected_output_device"),
]);
// Merge all settings
const mergedSettings: Settings = {
...settings,
always_on_microphone:
microphoneMode.status === "fulfilled"
? (microphoneMode.value as boolean)
: false,
selected_microphone:
selectedMicrophone.status === "fulfilled"
? (selectedMicrophone.value as string)
: "Default",
selected_output_device:
selectedOutputDevice.status === "fulfilled"
? (selectedOutputDevice.value as string)
: "Default",
};
set({ settings: mergedSettings, isLoading: false });
} catch (error) {
console.error("Failed to load settings:", error);
set({ isLoading: false });
}
},
// Load audio devices
refreshAudioDevices: async () => {
try {
const devices: AudioDevice[] = await invoke("get_available_microphones");
const devicesWithDefault = [
DEFAULT_AUDIO_DEVICE,
...devices.filter((d) => d.name !== "Default" && d.name !== "default"),
];
set({ audioDevices: devicesWithDefault });
} catch (error) {
console.error("Failed to load audio devices:", error);
set({ audioDevices: [DEFAULT_AUDIO_DEVICE] });
}
},
// Load output devices
refreshOutputDevices: async () => {
try {
const devices: AudioDevice[] = await invoke("get_available_output_devices");
const devicesWithDefault = [
DEFAULT_AUDIO_DEVICE,
...devices.filter((d) => d.name !== "Default" && d.name !== "default"),
];
set({ outputDevices: devicesWithDefault });
} catch (error) {
console.error("Failed to load output devices:", error);
set({ outputDevices: [DEFAULT_AUDIO_DEVICE] });
}
},
// Update a specific setting
updateSetting: async <K extends keyof Settings>(key: K, value: Settings[K]) => {
const { settings, setUpdating, refreshSettings } = get();
const updateKey = String(key);
const originalValue = settings?.[key];
setUpdating(updateKey, true);
try {
// Optimistic update
set((state) => ({
settings: state.settings ? { ...state.settings, [key]: value } : null,
}));
// Invoke the appropriate backend method
switch (key) {
case "always_on_microphone":
await invoke("update_microphone_mode", { alwaysOn: value });
break;
case "audio_feedback":
await invoke("change_audio_feedback_setting", { enabled: value });
break;
case "push_to_talk":
await invoke("change_ptt_setting", { enabled: value });
break;
case "selected_microphone":
const micDeviceName = value === "Default" ? "default" : value;
await invoke("set_selected_microphone", { deviceName: micDeviceName });
break;
case "selected_output_device":
const outputDeviceName = value === "Default" ? "default" : value;
await invoke("set_selected_output_device", { deviceName: outputDeviceName });
break;
case "translate_to_english":
await invoke("change_translate_to_english_setting", { enabled: value });
break;
case "selected_language":
await invoke("change_selected_language_setting", { language: value });
break;
case "overlay_position":
await invoke("change_overlay_position_setting", { position: value });
break;
case "debug_mode":
await invoke("change_debug_mode_setting", { enabled: value });
break;
case "custom_words":
await invoke("update_custom_words", { words: value });
break;
case "word_correction_threshold":
await invoke("change_word_correction_threshold_setting", { threshold: value });
break;
case "bindings":
case "selected_model":
break;
default:
console.warn(`No handler for setting: ${String(key)}`);
}
} catch (error) {
console.error(`Failed to update setting ${String(key)}:`, error);
// Rollback on error
set((state) => ({
settings: state.settings
? { ...state.settings, [key]: originalValue }
: null,
}));
} finally {
setUpdating(updateKey, false);
}
},
// Reset a setting to its default value
resetSetting: async (key) => {
const defaultValue = DEFAULT_SETTINGS[key];
if (defaultValue !== undefined) {
await get().updateSetting(key, defaultValue as any);
}
},
// Update a specific binding
updateBinding: async (id, binding) => {
const { settings, setUpdating } = get();
const updateKey = `binding_${id}`;
const originalBinding = settings?.bindings?.[id]?.current_binding;
setUpdating(updateKey, true);
try {
// Optimistic update
set((state) => ({
settings: state.settings
? {
...state.settings,
bindings: {
...state.settings.bindings,
[id]: {
...state.settings.bindings[id],
current_binding: binding,
},
},
}
: null,
}));
await invoke("change_binding", { id, binding });
} catch (error) {
console.error(`Failed to update binding ${id}:`, error);
// Rollback on error
if (originalBinding && get().settings) {
set((state) => ({
settings: state.settings
? {
...state.settings,
bindings: {
...state.settings.bindings,
[id]: {
...state.settings.bindings[id],
current_binding: originalBinding,
},
},
}
: null,
}));
}
} finally {
setUpdating(updateKey, false);
}
},
// Reset a specific binding
resetBinding: async (id) => {
const { setUpdating, refreshSettings } = get();
const updateKey = `binding_${id}`;
setUpdating(updateKey, true);
try {
await invoke("reset_binding", { id });
await refreshSettings();
} catch (error) {
console.error(`Failed to reset binding ${id}:`, error);
} finally {
setUpdating(updateKey, false);
}
},
// Initialize everything
initialize: async () => {
const { refreshSettings, refreshAudioDevices, refreshOutputDevices } = get();
await Promise.all([
refreshSettings(),
refreshAudioDevices(),
refreshOutputDevices(),
]);
},
}))
);