irongit

Open source voice dictation for your whole desktop. Local whisper.cpp transcription, global hotkeys, optional LLM enhancement via Claude Code, Gemini CLI, Codex, or any API. Wayland-first.

mutterbox/src/pages/General.tsx
340 lines10 KBJavaScript
1import { useEffect, useState } from "react";
2import { writeText } from "@tauri-apps/plugin-clipboard-manager";
3import { getEnvironment } from "../api";
4import type { EnvironmentInfo, Settings, TranscriptPayload } from "../types";
5
6const LANGUAGES = [
7 ["auto", "auto detect"],
8 ["en", "english"],
9 ["es", "spanish"],
10 ["fr", "french"],
11 ["de", "german"],
12 ["it", "italian"],
13 ["pt", "portuguese"],
14 ["nl", "dutch"],
15 ["ja", "japanese"],
16 ["ko", "korean"],
17 ["zh", "chinese"],
18 ["ru", "russian"],
19 ["hi", "hindi"],
20 ["ar", "arabic"],
21];
22
23/** Maps a KeyboardEvent to a tauri accelerator key token, or null for bare modifiers. */
24function keyFromEvent(e: React.KeyboardEvent): string | null {
25 const code = e.code;
26 if (code.startsWith("Key")) return code.slice(3);
27 if (code.startsWith("Digit")) return code.slice(5);
28 if (/^F\d{1,2}$/.test(code)) return code;
29 const map: Record<string, string> = {
30 Space: "Space",
31 Enter: "Enter",
32 Tab: "Tab",
33 Backspace: "Backspace",
34 ArrowUp: "Up",
35 ArrowDown: "Down",
36 ArrowLeft: "Left",
37 ArrowRight: "Right",
38 Home: "Home",
39 End: "End",
40 PageUp: "PageUp",
41 PageDown: "PageDown",
42 Comma: "Comma",
43 Period: "Period",
44 Slash: "Slash",
45 Backslash: "Backslash",
46 Minus: "Minus",
47 Equal: "Equal",
48 Semicolon: "Semicolon",
49 Quote: "Quote",
50 BracketLeft: "BracketLeft",
51 BracketRight: "BracketRight",
52 Backquote: "Backquote",
53 };
54 return map[code] ?? null;
55}
56
57/** Renders the hyprland.conf lines for the current hotkey and activation mode. */
58function hyprlandBinds(hotkey: string, hold: boolean, exe: string): string {
59 const parts = hotkey.split("+").map((s) => s.trim()).filter(Boolean);
60 const mods: string[] = [];
61 let key = "";
62 for (const part of parts) {
63 const up = part.toUpperCase();
64 if (up === "CTRL" || up === "CONTROL") mods.push("CTRL");
65 else if (up === "ALT" || up === "OPTION") mods.push("ALT");
66 else if (up === "SHIFT") mods.push("SHIFT");
67 else if (["SUPER", "META", "CMD", "COMMAND", "COMMANDORCONTROL"].includes(up))
68 mods.push("SUPER");
69 else key = up;
70 }
71 const KEY_NAMES: Record<string, string> = { ENTER: "RETURN" };
72 const hyprKey = KEY_NAMES[key] ?? key;
73 const m = mods.join(" ");
74 return hold
75 ? `bind = ${m}, ${hyprKey}, exec, ${exe} --start\nbindr = ${m}, ${hyprKey}, exec, ${exe} --stop`
76 : `bind = ${m}, ${hyprKey}, exec, ${exe} --toggle`;
77}
78
79function HotkeyCapture({
80 value,
81 onCapture,
82}: {
83 value: string;
84 onCapture: (accel: string) => void;
85}) {
86 const [capturing, setCapturing] = useState(false);
87
88 const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
89 if (!capturing) return;
90 e.preventDefault();
91 e.stopPropagation();
92 if (e.key === "Escape") {
93 setCapturing(false);
94 e.currentTarget.blur();
95 return;
96 }
97 const key = keyFromEvent(e);
98 if (!key) return; // modifier held alone; keep waiting
99 const mods = [
100 e.ctrlKey && "Ctrl",
101 e.altKey && "Alt",
102 e.shiftKey && "Shift",
103 e.metaKey && "Super",
104 ].filter(Boolean) as string[];
105 if (mods.length === 0 && !/^F\d{1,2}$/.test(key)) return; // demand a modifier
106 setCapturing(false);
107 e.currentTarget.blur();
108 onCapture([...mods, key].join("+"));
109 };
110
111 return (
112 <button
113 type="button"
114 className={capturing ? "hotkey-capture capturing" : "hotkey-capture"}
115 onClick={() => setCapturing(true)}
116 onKeyDown={onKeyDown}
117 onBlur={() => setCapturing(false)}
118 >
119 {capturing ? "press shortcut… (esc to cancel)" : value}
120 </button>
121 );
122}
123
124function HyprlandModal({
125 hotkey,
126 hold,
127 exe,
128 onClose,
129}: {
130 hotkey: string;
131 hold: boolean;
132 exe: string;
133 onClose: () => void;
134}) {
135 const [copied, setCopied] = useState(false);
136 const lines = hyprlandBinds(hotkey, hold, exe);
137
138 const copy = async () => {
139 try {
140 await writeText(lines);
141 setCopied(true);
142 setTimeout(() => setCopied(false), 1600);
143 } catch (err) {
144 console.error(err);
145 }
146 };
147
148 return (
149 <div className="modal-backdrop" onClick={onClose}>
150 <div className="modal" onClick={(e) => e.stopPropagation()}>
151 <h3>hyprland keybind</h3>
152 <p className="hint">
153 Hyprland doesn't let apps grab global keys, so mutterbox is triggered
154 from your compositor instead. Add this to{" "}
155 <code>~/.config/hypr/hyprland.conf</code>:
156 </p>
157 <div className="code-block">
158 <pre>{lines}</pre>
159 <button className="copy-btn" onClick={copy} title="copy to clipboard">
160 {copied ? "copied ✓" : "⧉ copy"}
161 </button>
162 </div>
163 <p className="hint">
164 Then run <code>hyprctl reload</code>. If mutterbox is on your PATH you
165 can shorten the exec to just <code>mutterbox</code>.
166 {hold &&
167 " Hold mode uses bind for press and bindr for release, so dictation records only while the key is held."}{" "}
168 mutterbox automatically makes its recording pill unfocusable while it
169 runs. To make that permanent, also add:{" "}
170 <code>
171 windowrule = match:title ^(mutterbox overlay)$, no_focus 1, float 1,
172 pin 1
173 </code>
174 </p>
175 <button className="btn" onClick={onClose}>
176 done
177 </button>
178 </div>
179 </div>
180 );
181}
182
183interface Props {
184 settings: Settings;
185 onChange: (next: Settings) => void;
186 lastTranscript: TranscriptPayload | null;
187}
188
189export default function GeneralPage({ settings, onChange, lastTranscript }: Props) {
190 const [env, setEnv] = useState<EnvironmentInfo | null>(null);
191 const [showHypr, setShowHypr] = useState(false);
192
193 useEffect(() => {
194 getEnvironment().then(setEnv).catch(console.error);
195 }, []);
196
197 const isHyprland = env?.compositor === "hyprland";
198 const hold = settings.activation === "hold";
199
200 const setHotkey = (hotkey: string) => {
201 onChange({ ...settings, hotkey });
202 if (isHyprland) setShowHypr(true);
203 };
204
205 const setActivation = (activation: Settings["activation"]) => {
206 if (activation === settings.activation) return;
207 onChange({ ...settings, activation });
208 if (isHyprland) setShowHypr(true);
209 };
210
211 return (
212 <section>
213 <header className="page-head">
214 <h2>general</h2>
215 <p>Hotkey, language, and how text lands in the focused field.</p>
216 </header>
217
218 <div className="field">
219 <label>global hotkey</label>
220 <div className="field-row">
221 <HotkeyCapture value={settings.hotkey} onCapture={setHotkey} />
222 {isHyprland && (
223 <button className="btn ghost" onClick={() => setShowHypr(true)}>
224 hyprland setup
225 </button>
226 )}
227 </div>
228 <p className="hint">
229 Click, then press the shortcut you want.
230 {isHyprland
231 ? " Hyprland detected: the hotkey is bound in your compositor config, see hyprland setup."
232 : env?.wayland
233 ? ` On Wayland, bind a compositor key to run mutterbox ${hold ? "--start / --stop" : "--toggle"} instead.`
234 : ""}
235 </p>
236 </div>
237
238 <div className="field">
239 <label>activation</label>
240 <div className="segmented">
241 <button
242 className={!hold ? "seg active" : "seg"}
243 onClick={() => setActivation("toggle")}
244 >
245 toggle
246 </button>
247 <button
248 className={hold ? "seg active" : "seg"}
249 onClick={() => setActivation("hold")}
250 >
251 hold
252 </button>
253 </div>
254 <p className="hint">
255 Toggle: press once to start, again to stop. Hold: records while the
256 hotkey is held, like push to talk.
257 </p>
258 </div>
259
260 <div className="field">
261 <label>spoken language</label>
262 <select
263 value={settings.language}
264 onChange={(e) => onChange({ ...settings, language: e.currentTarget.value })}
265 >
266 {LANGUAGES.map(([code, label]) => (
267 <option key={code} value={code}>
268 {label}
269 </option>
270 ))}
271 </select>
272 <p className="hint">Non-English languages need a multilingual model.</p>
273 </div>
274
275 <div className="field">
276 <label>text insertion</label>
277 <div className="segmented">
278 <button
279 className={settings.injection_mode === "paste" ? "seg active" : "seg"}
280 onClick={() => onChange({ ...settings, injection_mode: "paste" })}
281 >
282 paste
283 </button>
284 <button
285 className={settings.injection_mode === "type" ? "seg active" : "seg"}
286 onClick={() => onChange({ ...settings, injection_mode: "type" })}
287 >
288 keystrokes
289 </button>
290 </div>
291 <p className="hint">
292 Paste is fast and reliable. Keystrokes types character by character and
293 works in fields that block pasting.
294 </p>
295 </div>
296
297 {settings.injection_mode === "paste" && (
298 <div className="field">
299 <label className="check">
300 <input
301 type="checkbox"
302 checked={settings.restore_clipboard}
303 onChange={(e) =>
304 onChange({ ...settings, restore_clipboard: e.currentTarget.checked })
305 }
306 />
307 restore previous clipboard after pasting
308 </label>
309 </div>
310 )}
311
312 <div className="field">
313 <label>last transcript</label>
314 <div className="transcript-box">
315 {lastTranscript ? (
316 <>
317 <p>{lastTranscript.text}</p>
318 {lastTranscript.enhanced && (
319 <p className="hint">raw: {lastTranscript.raw}</p>
320 )}
321 </>
322 ) : (
323 <p className="hint">
324 Nothing yet. Press the hotkey, speak, press it again.
325 </p>
326 )}
327 </div>
328 </div>
329
330 {showHypr && env && (
331 <HyprlandModal
332 hotkey={settings.hotkey}
333 hold={hold}
334 exe={env.exe}
335 onClose={() => setShowHypr(false)}
336 />
337 )}
338 </section>
339 );
340}