* 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-updater": "~2",
"@tauri-apps/plugin-upload": "~2",
"lucide-react": "^0.542.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sonner": "^2.0.7",
"tailwindcss": "^4.0.2",
"tauri-plugin-macos-permissions-api": "^2.0.4",
"zod": "^3.24.4"
"zod": "^3.24.4",
"zustand": "^5.0.8"
},
"devDependencies": {
"@tauri-apps/cli": "^2",

View file

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

View file

@ -6,15 +6,50 @@ import AccessibilityPermissions from "./components/AccessibilityPermissions";
import Footer from "./components/footer";
import HandyTextLogo from "./components/icons/HandyTextLogo";
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() {
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
const [currentSection, setCurrentSection] =
useState<SidebarSection>("general");
const { settings, updateSetting } = useSettings();
useEffect(() => {
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 () => {
try {
// Always check if they have any models available
@ -33,23 +68,36 @@ function App() {
if (showOnboarding) {
return (
<div className="min-h-screen flex flex-col w-full">
<div className="flex flex-col items-center p-4 gap-4 flex-1">
<div className="h-screen flex flex-col">
<div className="flex-1 flex flex-col items-center justify-center p-4 gap-4">
<HandyTextLogo width={200} />
<Onboarding onModelSelected={handleModelSelected} />
</div>
<Footer />
</div>
);
}
return (
<div className="min-h-screen flex flex-col w-full">
<div className="h-screen flex flex-col">
<Toaster />
<div className="flex flex-col items-center p-4 gap-4 flex-1">
<HandyTextLogo width={200} />
<AccessibilityPermissions />
<Settings />
{/* Main content area that takes remaining space */}
<div className="flex-1 flex overflow-hidden">
<Sidebar
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>
{/* Fixed footer at bottom */}
<Footer />
</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 (
<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 items-center gap-4">
<ModelSelector />

File diff suppressed because one or more lines are too long

View file

@ -3,14 +3,17 @@ import React from "react";
const HandyTextLogo = ({
width,
height,
className,
}: {
width?: number;
height?: number;
className?: string;
}) => {
return (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 930 328"
fill="none"
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;
return (
<ToggleSwitch
checked={audioFeedbackEnabled}
onChange={(enabled) => updateSetting("audio_feedback", enabled)}
isUpdating={isUpdating("audio_feedback")}
label="Audio Feedback"
description="Play sound when recording starts and stops"
descriptionMode={descriptionMode}
grouped={grouped}
/>
);
});
return (
<ToggleSwitch
checked={audioFeedbackEnabled}
onChange={(enabled) => updateSetting("audio_feedback", enabled)}
isUpdating={isUpdating("audio_feedback")}
label="Audio Feedback"
description="Play sound when recording starts and stops"
descriptionMode={descriptionMode}
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 { ResetButton } from "../ui/ResetButton";
import { useSettings } from "../../hooks/useSettings";
import { AudioDevice } from "../../lib/types";
interface OutputDeviceSelectorProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
disabled?: boolean;
}
export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = React.memo(({
descriptionMode = "tooltip",
grouped = false,
}) => {
const {
getSetting,
updateSetting,
resetSetting,
isUpdating,
isLoading,
outputDevices,
refreshOutputDevices,
} = useSettings();
export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> =
React.memo(
({ descriptionMode = "tooltip", grouped = false, disabled = false }) => {
const {
getSetting,
updateSetting,
resetSetting,
isUpdating,
isLoading,
outputDevices,
refreshOutputDevices,
} = 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) => {
await updateSetting("selected_output_device", deviceName);
};
const handleOutputDeviceSelect = async (deviceName: string) => {
await updateSetting("selected_output_device", deviceName);
};
const handleReset = async () => {
await resetSetting("selected_output_device");
};
const handleReset = async () => {
await resetSetting("selected_output_device");
};
const outputDeviceOptions = outputDevices.map(device => ({
value: device.name,
label: device.name
}));
const outputDeviceOptions = outputDevices.map((device: AudioDevice) => ({
value: device.name,
label: device.name,
}));
return (
<SettingContainer
title="Output Device"
description="Select your preferred audio output device for feedback sounds"
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="flex items-center space-x-1">
<Dropdown
options={outputDeviceOptions}
selectedValue={selectedOutputDevice}
onSelect={handleOutputDeviceSelect}
placeholder={isLoading || outputDevices.length === 0 ? "Loading..." : "Select output device..."}
disabled={isUpdating("selected_output_device") || isLoading || outputDevices.length === 0}
onRefresh={refreshOutputDevices}
/>
<ResetButton
onClick={handleReset}
disabled={isUpdating("selected_output_device") || isLoading}
/>
</div>
</SettingContainer>
return (
<SettingContainer
title="Output Device"
description="Select your preferred audio output device for feedback sounds"
descriptionMode={descriptionMode}
grouped={grouped}
disabled={disabled}
>
<div className="flex items-center space-x-1">
<Dropdown
options={outputDeviceOptions}
selectedValue={selectedOutputDevice}
onSelect={handleOutputDeviceSelect}
placeholder={
isLoading || outputDevices.length === 0
? "Loading..."
: "Select output device..."
}
disabled={
disabled ||
isUpdating("selected_output_device") ||
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 { OutputDeviceSelector } from "./OutputDeviceSelector";
export { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";

View file

@ -17,7 +17,7 @@ export const Button: React.FC<ButtonProps> = ({
const variantClasses = {
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",
danger:
"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(
({ onClick, disabled = false, className = "" }) => (
<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}
disabled={disabled}
>

View file

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

View file

@ -1,15 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useEffect } from "react";
import { useSettingsStore } from "../stores/settingsStore";
import { Settings, AudioDevice } from "../lib/types";
interface SettingsState {
settings: Settings | null;
isLoading: boolean;
isUpdating: Record<string, boolean>;
audioDevices: AudioDevice[];
outputDevices: AudioDevice[];
}
interface UseSettingsReturn {
// State
settings: Settings | null;
@ -17,6 +9,7 @@ interface UseSettingsReturn {
isUpdating: (key: string) => boolean;
audioDevices: AudioDevice[];
outputDevices: AudioDevice[];
audioFeedbackEnabled: boolean;
// Actions
updateSetting: <K extends keyof Settings>(
@ -37,366 +30,29 @@ interface UseSettingsReturn {
}
export const useSettings = (): UseSettingsReturn => {
const [state, setState] = useState<SettingsState>({
settings: null,
isLoading: true,
isUpdating: {},
audioDevices: [],
outputDevices: [],
});
const store = useSettingsStore();
// Load settings from store
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
// Initialize on first mount
useEffect(() => {
loadSettings();
loadAudioDevices();
loadOutputDevices();
}, [loadSettings, loadAudioDevices, loadOutputDevices]);
if (store.isLoading) {
store.initialize();
}
}, [store.initialize, store.isLoading]);
return {
settings: state.settings,
isLoading: state.isLoading,
isUpdating: (key: string) => state.isUpdating[key] || false,
audioDevices: state.audioDevices,
outputDevices: state.outputDevices,
updateSetting,
resetSetting,
refreshSettings: loadSettings,
refreshAudioDevices: loadAudioDevices,
refreshOutputDevices: loadOutputDevices,
updateBinding,
resetBinding,
getSetting,
settings: store.settings,
isLoading: store.isLoading,
isUpdating: store.isUpdatingKey,
audioDevices: store.audioDevices,
outputDevices: store.outputDevices,
audioFeedbackEnabled: store.settings?.audio_feedback || false,
updateSetting: store.updateSetting,
resetSetting: store.resetSetting,
refreshSettings: store.refreshSettings,
refreshAudioDevices: store.refreshAudioDevices,
refreshOutputDevices: store.refreshOutputDevices,
updateBinding: store.updateBinding,
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(),
]);
},
}))
);