diff --git a/Cargo.lock b/Cargo.lock
index d9747d0..3dfd312 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -115,6 +115,24 @@ dependencies = [
"x11rb",
]
+[[package]]
+name = "ashpd"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
+dependencies = [
+ "async-fs",
+ "async-net",
+ "enumflags2",
+ "futures-channel",
+ "futures-util",
+ "rand 0.9.4",
+ "serde",
+ "serde_repr",
+ "url",
+ "zbus",
+]
+
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -153,6 +171,17 @@ dependencies = [
"slab",
]
+[[package]]
+name = "async-fs"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
+dependencies = [
+ "async-lock",
+ "blocking",
+ "futures-lite",
+]
+
[[package]]
name = "async-io"
version = "2.6.0"
@@ -182,6 +211,17 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "async-net"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
+dependencies = [
+ "async-io",
+ "blocking",
+ "futures-lite",
+]
+
[[package]]
name = "async-process"
version = "2.5.0"
@@ -275,6 +315,57 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+[[package]]
+name = "atspi"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bf601cccedfffec598ec2db1f9d6745885458bccc0e8916d7023f017c94b3d0"
+dependencies = [
+ "atspi-common",
+ "atspi-connection",
+ "atspi-proxies",
+ "zbus",
+]
+
+[[package]]
+name = "atspi-common"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8a79bed3f5b408ce3152f36e07327a845e6ed5d7e2821a89264037dbcc11daf"
+dependencies = [
+ "enumflags2",
+ "serde",
+ "static_assertions",
+ "zbus",
+ "zbus-lockstep",
+ "zbus-lockstep-macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "atspi-connection"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fab8e4f574f5a7d3af280b38eff25fb6f47a537dac9ae39ce152f52b19fb10b"
+dependencies = [
+ "atspi-common",
+ "atspi-proxies",
+ "futures-lite",
+ "zbus",
+]
+
+[[package]]
+name = "atspi-proxies"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53403acd3ab2fdb5914f6558da22e540fc07656fce5510f8c02be0e6ef68413e"
+dependencies = [
+ "atspi-common",
+ "serde",
+ "zbus",
+]
+
[[package]]
name = "auto-launch"
version = "0.5.0"
@@ -2215,6 +2306,8 @@ version = "0.38.1"
dependencies = [
"aes-gcm",
"anyhow",
+ "ashpd",
+ "atspi",
"base64 0.22.1",
"block2 0.6.2",
"chrono",
@@ -2228,6 +2321,7 @@ dependencies = [
"ort",
"oxipng",
"parking_lot",
+ "pollster",
"rand 0.8.6",
"rusqlite",
"serde",
@@ -2243,6 +2337,7 @@ dependencies = [
"tauri-plugin-single-instance",
"tracing",
"tracing-subscriber",
+ "url",
"windows 0.61.3",
]
@@ -3654,6 +3749,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "pollster"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
+
[[package]]
name = "polyval"
version = "0.6.2"
@@ -3817,6 +3918,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
dependencies = [
"memchr",
+ "serde",
]
[[package]]
@@ -3871,6 +3973,16 @@ dependencies = [
"rand_core 0.6.4",
]
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand_chacha"
version = "0.2.2"
@@ -3891,6 +4003,16 @@ dependencies = [
"rand_core 0.6.4",
]
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -3909,6 +4031,15 @@ dependencies = [
"getrandom 0.2.17",
]
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -4596,6 +4727,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
[[package]]
name = "string_cache"
version = "0.8.9"
@@ -7117,6 +7254,30 @@ dependencies = [
"zvariant",
]
+[[package]]
+name = "zbus-lockstep"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863"
+dependencies = [
+ "zbus_xml",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus-lockstep-macros"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zbus-lockstep",
+ "zbus_xml",
+ "zvariant",
+]
+
[[package]]
name = "zbus_macros"
version = "5.14.0"
@@ -7134,12 +7295,24 @@ dependencies = [
[[package]]
name = "zbus_names"
-version = "4.3.1"
+version = "4.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
+checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
dependencies = [
"serde",
- "winnow 0.7.15",
+ "winnow 1.0.2",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_xml"
+version = "5.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9"
+dependencies = [
+ "quick-xml 0.39.2",
+ "serde",
+ "zbus_names",
"zvariant",
]
@@ -7258,23 +7431,24 @@ dependencies = [
[[package]]
name = "zvariant"
-version = "5.10.0"
+version = "5.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
+checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee"
dependencies = [
"endi",
"enumflags2",
"serde",
- "winnow 0.7.15",
+ "url",
+ "winnow 1.0.2",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
-version = "5.10.0"
+version = "5.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
+checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
@@ -7285,13 +7459,13 @@ dependencies = [
[[package]]
name = "zvariant_utils"
-version = "3.3.0"
+version = "3.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
+checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.117",
- "winnow 0.7.15",
+ "winnow 1.0.2",
]
diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx
index 2d872b8..dfb55f0 100644
--- a/core/frontend/src/App.tsx
+++ b/core/frontend/src/App.tsx
@@ -645,6 +645,17 @@ function App() {
return () => unlisten?.();
}, [refreshNotes]);
+ // After expander diagnose, backend re-opens the popup — stay on Settings.
+ useEffect(() => {
+ let unlisten: UnlistenFn | undefined;
+ (async () => {
+ unlisten = await listen("expander-diagnose-ready", () => {
+ setActiveTab("settings");
+ });
+ })();
+ return () => unlisten?.();
+ }, []);
+
// Backend fires this when the OCR shortcut is pressed but the
// Screen Recording TCC grant is missing. Switch to Settings (which
// shows the Permissions overview) and surface a banner so the
diff --git a/core/frontend/src/components/PreviewPanel.tsx b/core/frontend/src/components/PreviewPanel.tsx
index 4c0c311..ec1865e 100644
--- a/core/frontend/src/components/PreviewPanel.tsx
+++ b/core/frontend/src/components/PreviewPanel.tsx
@@ -18,6 +18,47 @@ interface Props {
entry: ListEntry | null;
}
+function BrunoDetailRow({
+ k,
+ v,
+ accent,
+}: {
+ k: string;
+ v: string;
+ accent?: boolean;
+}) {
+ return (
+
+ {k}
+ {v}
+
+ );
+}
+
+const BRUNO_STATE_LABELS: Record = {
+ bw: "Baden-Württemberg",
+ by: "Bayern",
+ be: "Berlin",
+ bb: "Brandenburg",
+ hb: "Bremen",
+ hh: "Hamburg",
+ he: "Hessen",
+ mv: "Mecklenburg-Vorp.",
+ ni: "Niedersachsen",
+ nw: "Nordrhein-Westfalen",
+ rp: "Rheinland-Pfalz",
+ sl: "Saarland",
+ sn: "Sachsen",
+ st: "Sachsen-Anhalt",
+ sh: "Schleswig-Holstein",
+ th: "Thüringen",
+};
+
export function PreviewPanel({ entry }: Props) {
const parsedFiles = useMemo(() => {
if (!entry || entry.kind !== "clip" || entry.data.content_type !== "files") return null;
@@ -233,24 +274,6 @@ export function PreviewPanel({ entry }: Props) {
maximumFractionDigits: 1,
});
const d = entry.data;
- const Row = ({ k, v, accent }: { k: string; v: string; accent?: boolean }) => (
-
- {k}
- {v}
-
- );
- const STATE_LABELS: Record = {
- bw: "Baden-Württemberg", by: "Bayern", be: "Berlin", bb: "Brandenburg",
- hb: "Bremen", hh: "Hamburg", he: "Hessen", mv: "Mecklenburg-Vorp.",
- ni: "Niedersachsen", nw: "Nordrhein-Westfalen", rp: "Rheinland-Pfalz",
- sl: "Saarland", sn: "Sachsen", st: "Sachsen-Anhalt",
- sh: "Schleswig-Holstein", th: "Thüringen",
- };
return (
@@ -261,7 +284,7 @@ export function PreviewPanel({ entry }: Props) {
Annahmen
- Klasse {d.taxClass} · {STATE_LABELS[d.state] ?? d.state.toUpperCase()} ·{" "}
+ Klasse {d.taxClass} · {BRUNO_STATE_LABELS[d.state] ?? d.state.toUpperCase()} ·{" "}
{d.children === 0 ? "kinderlos" : `${d.children} Kind${d.children === 1 ? "" : "er"}`}{" "}
· {d.isChurchMember ? "kirchensteuerpflichtig" : "keine Kirchensteuer"}
@@ -271,23 +294,23 @@ export function PreviewPanel({ entry }: Props) {
-
-
-
-
-
-
-
- {d.soli > 0 &&
}
- {d.churchTax > 0 &&
}
-
-
-
+
+
+
+
+
+
+
+ {d.soli > 0 && }
+ {d.churchTax > 0 && }
+
+
+
-
-
+
+
diff --git a/core/frontend/src/components/ScreenshotEditor.tsx b/core/frontend/src/components/ScreenshotEditor.tsx
index 6970fdd..76b7d23 100644
--- a/core/frontend/src/components/ScreenshotEditor.tsx
+++ b/core/frontend/src/components/ScreenshotEditor.tsx
@@ -96,6 +96,7 @@ const COLOR_PRESETS = ["#ef4444", "#facc15", "#ffffff", "#000000"] as const;
export function ScreenshotEditor() {
const canvasRef = useRef
(null);
+ const [canvasEl, setCanvasEl] = useState(null);
/** The decoded screenshot image. Kept in a ref because we need it
* for every redraw (background) and for blur (sampling source
* pixels). State would re-create the Image on every render. */
@@ -409,7 +410,10 @@ export function ScreenshotEditor() {
{imgReady ? (
{
+ canvasRef.current = el;
+ setCanvasEl(el);
+ }}
onMouseDown={onCanvasMouseDown}
onMouseMove={onCanvasMouseMove}
onMouseUp={onCanvasMouseUp}
@@ -426,7 +430,7 @@ export function ScreenshotEditor() {
/>
{textInput && (
void;
+ icon: React.ReactNode;
+ label: string;
+ shortcut: string;
+}) {
+ return (
+
+ {icon}
+
+ );
+}
+
function Toolbar({
tool,
setTool,
@@ -466,43 +499,43 @@ function Toolbar({
strokeWidth: number;
setStrokeWidth: (n: number) => void;
}) {
- const Btn = ({
- t,
- icon,
- label,
- shortcut,
- }: {
- t: Tool;
- icon: React.ReactNode;
- label: string;
- shortcut: string;
- }) => (
- setTool(t)}
- title={`${label} (${shortcut})`}
- className={
- "flex h-10 w-10 items-center justify-center rounded-md border transition-colors " +
- (tool === t
- ? "border-[var(--color-accent)] bg-[var(--color-accent)]/15 text-[var(--color-accent)]"
- : "border-[var(--color-border)] hover:bg-[var(--color-bg)]")
- }
- >
- {icon}
-
- );
-
return (
-
} label="Arrow" shortcut="A" />
-
} label="Text" shortcut="T" />
-
} label="Rectangle" shortcut="R" />
-
setTool("arrow")}
+ icon={ }
+ label="Arrow"
+ shortcut="A"
+ />
+ setTool("text")}
+ icon={ }
+ label="Text"
+ shortcut="T"
+ />
+ setTool("rect")}
+ icon={ }
+ label="Rectangle"
+ shortcut="R"
+ />
+ setTool("highlight")}
icon={ }
label="Highlight"
shortcut="H"
/>
- } label="Blur" shortcut="B" />
+ setTool("blur")}
+ icon={ }
+ label="Blur"
+ shortcut="B"
+ />
{COLOR_PRESETS.map((c) => (
diff --git a/core/frontend/src/components/SettingsPanel.tsx b/core/frontend/src/components/SettingsPanel.tsx
index 2aa4731..82639f6 100644
Binary files a/core/frontend/src/components/SettingsPanel.tsx and b/core/frontend/src/components/SettingsPanel.tsx differ
diff --git a/core/frontend/src/lib/ipc.ts b/core/frontend/src/lib/ipc.ts
index 4130926..00e33c4 100644
--- a/core/frontend/src/lib/ipc.ts
+++ b/core/frontend/src/lib/ipc.ts
@@ -470,7 +470,7 @@ export interface DiagnoseResult {
matched_abbreviation: string | null;
paste_preview: string | null;
/** Which capture mechanism was actually used. */
- path: "ax" | "uia" | "clipboard";
+ path: "ax" | "uia" | "atspi" | "clipboard";
}
/** Capture the word before the cursor (select prev word + copy) and run
diff --git a/core/rust-lib/Cargo.toml b/core/rust-lib/Cargo.toml
index ae1f1d9..72f4ba1 100644
--- a/core/rust-lib/Cargo.toml
+++ b/core/rust-lib/Cargo.toml
@@ -64,6 +64,12 @@ windows = { version = "0.61", features = [
# macOS-only Cocoa bindings for the screen color picker (NSColorSampler).
# objc2 + block2 give us safe-ish msg_send + stack-allocated Objective-C
# blocks without dragging in the deprecated `cocoa` crate.
+[target.'cfg(target_os = "linux")'.dependencies]
+ashpd = { version = "0.11", default-features = false, features = ["async-std"] }
+atspi = { version = "0.30.0", default-features = true, features = ["zbus"] }
+pollster = "0.4"
+url = "2"
+
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
block2 = "0.6"
diff --git a/core/rust-lib/src/cli_dispatch.rs b/core/rust-lib/src/cli_dispatch.rs
index 96ea63f..9ae4525 100644
--- a/core/rust-lib/src/cli_dispatch.rs
+++ b/core/rust-lib/src/cli_dispatch.rs
@@ -14,6 +14,8 @@ pub enum CliAction {
PickColor,
/// Re-scan gsettings conflicts and reinstall desktop shortcuts (Linux).
SetupShortcuts,
+ /// Text expander: capture word before cursor and paste snippet (Linux Wayland).
+ ExpandAtCursor,
}
/// Parse `argv` (program name + flags). Returns the first recognized action.
@@ -30,6 +32,7 @@ where
"--pick-color" | "--color" => return Some(CliAction::PickColor),
#[cfg(target_os = "linux")]
"--setup-shortcuts" => return Some(CliAction::SetupShortcuts),
+ "--expand-at-cursor" | "--expand" => return Some(CliAction::ExpandAtCursor),
"--help" | "-h" => {
print_help();
return None;
@@ -62,6 +65,7 @@ pub fn print_help() {
--screenshot Capture region to clipboard (Ctrl+Shift+S)\n\
--pick-color Pick pixel color to clipboard (Ctrl+Shift+C)\n\
--setup-shortcuts (Linux) Re-scan shortcut conflicts and reinstall bindings\n\
+ --expand-at-cursor (Linux) Run text expander once (if global hotkey does not fire)\n\
\n\
On GNOME/Cinnamon + Wayland, shortcuts are installed automatically on first start\n\
(conflict scan moves Terminal to Ctrl+C/V when needed; fallbacks if keys are taken).\n"
@@ -82,7 +86,8 @@ pub fn dispatch(app: &AppHandle, action: CliAction) {
Ok(r) if !r.cancelled && r.chars > 0 => {
tracing::info!("--ocr: {} chars", r.chars);
}
- Ok(_) => tracing::debug!("--ocr: cancelled or empty"),
+ Ok(r) if r.cancelled => tracing::info!("--ocr: cancelled by user"),
+ Ok(_) => tracing::warn!("--ocr: empty result (no text in region?)"),
Err(e) => tracing::warn!("--ocr failed: {e}"),
});
}
@@ -92,7 +97,8 @@ pub fn dispatch(app: &AppHandle, action: CliAction) {
Ok(r) if !r.cancelled && r.bytes > 0 => {
tracing::info!("--screenshot: {} bytes", r.bytes);
}
- Ok(_) => tracing::debug!("--screenshot: cancelled or empty"),
+ Ok(r) if r.cancelled => tracing::info!("--screenshot: cancelled by user"),
+ Ok(_) => tracing::warn!("--screenshot: empty capture"),
Err(e) => tracing::warn!("--screenshot failed: {e}"),
});
}
@@ -115,6 +121,20 @@ pub fn dispatch(app: &AppHandle, action: CliAction) {
CliAction::SetupShortcuts => {
tracing::warn!("--setup-shortcuts is only available on Linux");
}
+ CliAction::ExpandAtCursor => {
+ hotkey::hide_popup(app);
+ let app2 = app.clone();
+ let _ = app.run_on_main_thread(move || {
+ std::thread::sleep(std::time::Duration::from_millis(250));
+ if let Some(db) = app2.try_state::
() {
+ let watcher = app2.try_state::();
+ match crate::expander::expand_at_cursor(&db, watcher.as_deref()) {
+ Ok(()) => tracing::info!("--expand-at-cursor: expansion completed"),
+ Err(e) => tracing::warn!("--expand-at-cursor failed: {e:#}"),
+ }
+ }
+ });
+ }
}
}
diff --git a/core/rust-lib/src/commands.rs b/core/rust-lib/src/commands.rs
index b146c0f..41a4bb7 100644
--- a/core/rust-lib/src/commands.rs
+++ b/core/rust-lib/src/commands.rs
@@ -6,6 +6,7 @@ use crate::backup::{self, BackupImportResult};
use crate::clipboard_watcher::WatcherState;
use crate::cutout_ml;
use crate::db::{self, DbHandle};
+use crate::desktop_shortcuts;
use crate::expander;
use crate::hotkey::{self, ExpanderShortcutState};
use crate::models::ClipEntry;
@@ -18,8 +19,6 @@ use crate::screen_recording;
use crate::seed;
use crate::settings;
use crate::snippets::{self, ImportResult, Snippet};
-#[cfg(target_os = "linux")]
-use crate::desktop_shortcuts;
use crate::ui_state::UiState;
fn map_err(e: E) -> String {
@@ -145,10 +144,7 @@ pub fn get_paste_plain_text_only(db: State<'_, DbHandle>) -> Result,
- value: bool,
-) -> Result<(), String> {
+pub fn set_paste_plain_text_only(db: State<'_, DbHandle>, value: bool) -> Result<(), String> {
settings::set(
&db,
KEY_PLAIN_TEXT_ONLY,
@@ -167,10 +163,7 @@ pub fn get_ocr_save_source_image(db: State<'_, DbHandle>) -> Result,
- value: bool,
-) -> Result<(), String> {
+pub fn set_ocr_save_source_image(db: State<'_, DbHandle>, value: bool) -> Result<(), String> {
settings::set(
&db,
KEY_OCR_SAVE_SOURCE,
@@ -199,10 +192,7 @@ pub fn get_input_lock_chord(db: State<'_, DbHandle>) -> Result, Stri
/// chords so the user can never lock themselves out by saving an
/// unusable chord.
#[tauri::command]
-pub fn set_input_lock_chord(
- db: State<'_, DbHandle>,
- keys: Vec,
-) -> Result<(), String> {
+pub fn set_input_lock_chord(db: State<'_, DbHandle>, keys: Vec) -> Result<(), String> {
if keys.is_empty() {
return Err("chord cannot be empty".into());
}
@@ -212,18 +202,14 @@ pub fn set_input_lock_chord(
if !any_valid {
return Err("chord contains no recognised keys".into());
}
- let json =
- serde_json::to_string(&keys).map_err(|e| format!("serialise chord: {e}"))?;
+ let json = serde_json::to_string(&keys).map_err(|e| format!("serialise chord: {e}"))?;
settings::set(&db, KEY_INPUT_LOCK_CHORD, &json).map_err(map_err)
}
/// Activate the input lock. Reads the persisted unlock chord from
/// settings and hands it to `input_lock::start_input_lock`.
#[tauri::command]
-pub fn start_input_lock(
- db: State<'_, DbHandle>,
- app: AppHandle,
-) -> Result<(), String> {
+pub fn start_input_lock(db: State<'_, DbHandle>, app: AppHandle) -> Result<(), String> {
let chord = get_input_lock_chord(db)?;
// Hide the popup so the user isn't visually staring at an open
// window that can no longer accept clicks.
@@ -350,10 +336,7 @@ pub fn get_theme_preference(db: State<'_, DbHandle>) -> Result {
/// Persist the theme preference. Rejects anything that isn't one of
/// the three valid values rather than silently storing garbage.
#[tauri::command]
-pub fn set_theme_preference(
- db: State<'_, DbHandle>,
- theme: String,
-) -> Result<(), String> {
+pub fn set_theme_preference(db: State<'_, DbHandle>, theme: String) -> Result<(), String> {
let normalised = normalise_theme(&theme);
if normalised != theme {
return Err(format!(
@@ -449,10 +432,7 @@ pub fn list_snippets(db: State<'_, DbHandle>) -> Result, String> {
}
#[tauri::command]
-pub fn find_snippets(
- db: State<'_, DbHandle>,
- query: String,
-) -> Result, String> {
+pub fn find_snippets(db: State<'_, DbHandle>, query: String) -> Result, String> {
snippets::find_by_query(&db, &query).map_err(map_err)
}
@@ -468,8 +448,7 @@ pub fn upsert_snippet(
match id {
None => snippets::create(&db, &abbreviation, &title, &body).map_err(map_err),
Some(existing_id) => {
- snippets::update(&db, existing_id, &abbreviation, &title, &body)
- .map_err(map_err)?;
+ snippets::update(&db, existing_id, &abbreviation, &title, &body).map_err(map_err)?;
Ok(existing_id)
}
}
@@ -505,10 +484,7 @@ pub fn paste_snippet(
/// abbreviation are overwritten. Per-row errors are returned in the result
/// instead of aborting the whole import.
#[tauri::command]
-pub fn import_snippets(
- db: State<'_, DbHandle>,
- json: String,
-) -> Result {
+pub fn import_snippets(db: State<'_, DbHandle>, json: String) -> Result {
snippets::import_from_json(&db, &json).map_err(map_err)
}
@@ -519,8 +495,7 @@ pub fn import_snippets_from_file(
db: State<'_, DbHandle>,
path: String,
) -> Result {
- let json = std::fs::read_to_string(&path)
- .map_err(|e| format!("read {path}: {e}"))?;
+ let json = std::fs::read_to_string(&path).map_err(|e| format!("read {path}: {e}"))?;
snippets::import_from_json(&db, &json).map_err(map_err)
}
@@ -642,8 +617,7 @@ pub fn paste_note_formatted(
hotkey::hide_popup(&app);
watcher.mark_self_write(note.content_type, ¬e.content_data);
- paste::paste_payload(note.content_type, ¬e.content_data, ¬e.content_text)
- .map_err(map_err)
+ paste::paste_payload(note.content_type, ¬e.content_data, ¬e.content_text).map_err(map_err)
}
// ── Backup (full app export / import) ────────────────────────────────────────
@@ -693,12 +667,8 @@ pub fn save_backup_to_file(
/// (snippets upsert by abbreviation, history dedupes by hash, notes are
/// appended).
#[tauri::command]
-pub fn import_backup(
- db: State<'_, DbHandle>,
- path: String,
-) -> Result {
- let json = std::fs::read_to_string(&path)
- .map_err(|e| format!("read {path}: {e}"))?;
+pub fn import_backup(db: State<'_, DbHandle>, path: String) -> Result {
+ let json = std::fs::read_to_string(&path).map_err(|e| format!("read {path}: {e}"))?;
backup::import_json(&db, &json).map_err(map_err)
}
@@ -718,8 +688,8 @@ pub struct ExpanderConfig {
#[tauri::command]
pub fn get_expander_config(db: State<'_, DbHandle>) -> Result {
let enabled = settings::get_bool(&db, expander::KEY_ENABLED, false).map_err(map_err)?;
- let hotkey = settings::get_or(&db, expander::KEY_HOTKEY, expander::DEFAULT_HOTKEY)
- .map_err(map_err)?;
+ let hotkey =
+ settings::get_or(&db, expander::KEY_HOTKEY, expander::DEFAULT_HOTKEY).map_err(map_err)?;
Ok(ExpanderConfig {
enabled,
hotkey,
@@ -1049,6 +1019,9 @@ pub fn set_expander_config(
// don't touch the persisted settings.
hotkey::register_expander(&app, &state, &hotkey, enabled).map_err(map_err)?;
+ #[cfg(target_os = "linux")]
+ desktop_shortcuts::sync_expander_shortcut(&db, enabled, &hotkey).map_err(map_err)?;
+
settings::set(&db, expander::KEY_HOTKEY, &hotkey).map_err(map_err)?;
settings::set(
&db,
@@ -1098,9 +1071,7 @@ pub fn trigger_expand_at_cursor(app: AppHandle) -> Result<(), String> {
/// blocking `mpsc` to ferry the result back from the main-thread closure
/// to the IPC handler thread.
#[tauri::command]
-pub fn diagnose_expand_at_cursor(
- app: AppHandle,
-) -> Result {
+pub fn diagnose_expand_at_cursor(app: AppHandle) -> Result {
hotkey::hide_popup(&app);
let app2 = app.clone();
let (tx, rx) = std::sync::mpsc::channel();
@@ -1113,8 +1084,26 @@ pub fn diagnose_expand_at_cursor(
let _ = tx.send(result);
})
.map_err(|e| format!("dispatch to main thread: {e}"))?;
- rx.recv()
- .map_err(|e| format!("main thread didn't reply: {e}"))?
+ let result = rx
+ .recv()
+ .map_err(|e| format!("main thread didn't reply: {e}"))?;
+ match &result {
+ Ok(data) => {
+ tracing::info!(
+ "expander diagnose: path={:?} captured={:?} matched={:?}",
+ data.path,
+ data.captured,
+ data.matched_abbreviation
+ );
+ let _ = app.emit("expander-diagnose-result", data);
+ let _ = app.emit("expander-diagnose-ready", ());
+ }
+ Err(e) => tracing::warn!("expander diagnose failed: {e}"),
+ }
+ // Re-open the popup on Settings so the user sees the result (hide_popup
+ // alone makes it look like the app crashed).
+ let _ = hotkey::show_popup(&app);
+ result
}
// ── Direct hotkey → snippet slots ───────────────────────────────────────────
@@ -1171,7 +1160,10 @@ pub fn set_direct_slots(
})
.collect();
for s in &parsed {
- if snippets::get_by_id(&db, s.snippet_id).map_err(map_err)?.is_none() {
+ if snippets::get_by_id(&db, s.snippet_id)
+ .map_err(map_err)?
+ .is_none()
+ {
return Err(format!("snippet id {} no longer exists", s.snippet_id));
}
}
@@ -1223,7 +1215,10 @@ pub fn recolor_image_entry(
// Use the brightness/dimensions plus the chosen tint as the
// human-readable preview line. Keeps it visually distinct from the
// source entry in the history list.
- let summary = format!("[image · tinted #{}]", hex.trim_start_matches('#').to_uppercase());
+ let summary = format!(
+ "[image · tinted #{}]",
+ hex.trim_start_matches('#').to_uppercase()
+ );
let new_id = db::upsert_clip(
&db,
@@ -1245,10 +1240,7 @@ pub fn recolor_image_entry(
/// is in [0, 1] — frontend treats anything below ~0.1 as "looks
/// monochrome, recolor button worth showing".
#[tauri::command]
-pub fn image_chromaticity(
- db: State<'_, DbHandle>,
- id: i64,
-) -> Result {
+pub fn image_chromaticity(db: State<'_, DbHandle>, id: i64) -> Result {
use base64::{engine::general_purpose::STANDARD as B64, Engine};
let entry = db::get(&db, id)
@@ -1309,7 +1301,11 @@ pub fn run_ocr_pipeline(app: &AppHandle) -> Result {
Err(e) => {
// Distinguish "user cancelled" from a real error.
if e.downcast_ref::().is_some() {
- return Ok(OcrResult { text: String::new(), cancelled: true, chars: 0 });
+ return Ok(OcrResult {
+ text: String::new(),
+ cancelled: true,
+ chars: 0,
+ });
}
return Err(format!("region capture failed: {e:#}"));
}
@@ -1318,7 +1314,11 @@ pub fn run_ocr_pipeline(app: &AppHandle) -> Result {
let text = ocr::recognize(&png_bytes).map_err(|e| format!("ocr failed: {e:#}"))?;
let trimmed = text.trim();
if trimmed.is_empty() {
- return Ok(OcrResult { text: String::new(), cancelled: false, chars: 0 });
+ return Ok(OcrResult {
+ text: String::new(),
+ cancelled: false,
+ chars: 0,
+ });
}
// Write to system clipboard. Mark first so the watcher doesn't
@@ -1326,8 +1326,7 @@ pub fn run_ocr_pipeline(app: &AppHandle) -> Result {
if let Some(watcher) = app.try_state::() {
watcher.mark_self_write(crate::models::ContentType::Text, trimmed);
}
- let ctx = ClipboardContext::new()
- .map_err(|e| format!("clipboard ctx init: {e:?}"))?;
+ let ctx = ClipboardContext::new().map_err(|e| format!("clipboard ctx init: {e:?}"))?;
ctx.set_text(trimmed.to_string())
.map_err(|e| format!("set_text: {e:?}"))?;
@@ -1370,7 +1369,11 @@ pub fn run_ocr_pipeline(app: &AppHandle) -> Result {
let _ = app.emit("clipboard-changed", ());
let chars = trimmed.chars().count();
- Ok(OcrResult { text: trimmed.to_string(), cancelled: false, chars })
+ Ok(OcrResult {
+ text: trimmed.to_string(),
+ cancelled: false,
+ chars,
+ })
}
/// IPC entry point — the menu / button caller. Dispatched to a thread
@@ -1423,7 +1426,10 @@ pub fn run_screenshot_pipeline(app: &AppHandle) -> Result b,
Err(e) => {
if e.downcast_ref::().is_some() {
- return Ok(ScreenshotResult { cancelled: true, bytes: 0 });
+ return Ok(ScreenshotResult {
+ cancelled: true,
+ bytes: 0,
+ });
}
return Err(format!("region capture failed: {e:#}"));
}
@@ -1453,9 +1459,7 @@ pub fn run_screenshot_pipeline(app: &AppHandle) -> Result() {
watcher.mark_self_write(crate::models::ContentType::Image, &b64);
@@ -1513,7 +1517,10 @@ pub fn run_screenshot_pipeline(app: &AppHandle) -> Result write_eyedropper_result(&app_for_thread, &hex),
+ Ok(None) => tracing::debug!("color pick cancelled"),
+ Err(e) => tracing::warn!("eyedropper pipeline (portal): {e:#}"),
+ }
+ clear_eyedropper_no_popup(&app_for_thread);
+ });
+ }
+ #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
clear_eyedropper_no_popup(app);
}
@@ -1843,10 +1862,21 @@ pub fn strip_vowels(s: &str) -> String {
.filter(|c| {
!matches!(
c,
- 'a' | 'e' | 'i' | 'o' | 'u'
- | 'A' | 'E' | 'I' | 'O' | 'U'
- | 'ä' | 'ö' | 'ü'
- | 'Ä' | 'Ö' | 'Ü'
+ 'a' | 'e'
+ | 'i'
+ | 'o'
+ | 'u'
+ | 'A'
+ | 'E'
+ | 'I'
+ | 'O'
+ | 'U'
+ | 'ä'
+ | 'ö'
+ | 'ü'
+ | 'Ä'
+ | 'Ö'
+ | 'Ü'
)
})
.collect()
@@ -1984,10 +2014,7 @@ fn clear_eyedropper_no_popup(app: &AppHandle) {
/// this is a "save the cutout to a file" action, not a clipboard
/// modification.
#[tauri::command]
-pub fn cut_out_image_entry(
- db: State<'_, DbHandle>,
- id: i64,
-) -> Result {
+pub fn cut_out_image_entry(db: State<'_, DbHandle>, id: i64) -> Result {
use base64::{engine::general_purpose::STANDARD as B64, Engine};
let entry = db::get(&db, id)
@@ -2009,10 +2036,7 @@ pub fn cut_out_image_entry(
/// recolor. Particularly useful after a recolor since the new tinted
/// entry only lives in the SQLite history otherwise.
#[tauri::command]
-pub fn save_image_entry_to_downloads(
- db: State<'_, DbHandle>,
- id: i64,
-) -> Result {
+pub fn save_image_entry_to_downloads(db: State<'_, DbHandle>, id: i64) -> Result {
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use chrono::Local;
@@ -2114,10 +2138,7 @@ pub fn linux_apply_desktop_shortcuts(
db: State<'_, DbHandle>,
bindings: Vec,
) -> Result<(), String> {
- let pairs: Vec<(String, String)> = bindings
- .into_iter()
- .map(|b| (b.id, b.binding))
- .collect();
+ let pairs: Vec<(String, String)> = bindings.into_iter().map(|b| (b.id, b.binding)).collect();
desktop_shortcuts::apply_shortcut_setup(&db, pairs).map_err(map_err)
}
diff --git a/core/rust-lib/src/desktop_shortcuts.rs b/core/rust-lib/src/desktop_shortcuts.rs
index 21f6731..4db8180 100644
--- a/core/rust-lib/src/desktop_shortcuts.rs
+++ b/core/rust-lib/src/desktop_shortcuts.rs
@@ -116,11 +116,13 @@ mod imp {
},
];
- /// GNOME Terminal defaults that steal Inspector's Ctrl+Shift+C/V.
+ /// GNOME Terminal defaults — Inspector must **not** steal these; pick fallbacks instead.
const TERMINAL_SHIFT_COPY: &str = "c";
const TERMINAL_SHIFT_PASTE: &str = "v";
- const TERMINAL_STD_COPY: &str = "c";
- const TERMINAL_STD_PASTE: &str = "v";
+ /// Broken state from an earlier Inspector build that wrongly moved Terminal here.
+ /// In terminals Ctrl+C is SIGINT, not copy — never assign these as copy/paste.
+ const TERMINAL_BROKEN_COPY: &str = "c";
+ const TERMINAL_BROKEN_PASTE: &str = "v";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinuxDesktop {
@@ -286,9 +288,10 @@ mod imp {
Ok(occupied)
}
- /// Move GNOME Terminal off Ctrl+Shift+C/V when that would block Inspector (or is non-standard).
- /// Returns number of profiles updated.
- pub(crate) fn reconcile_terminal_copy_paste() -> Result {
+ /// Restore GNOME Terminal copy/paste to Ctrl+Shift+C/V when a previous
+ /// Inspector build wrongly moved them to Ctrl+C/V (SIGINT / broken paste).
+ /// Idempotent — safe to call on every startup.
+ pub(crate) fn restore_terminal_copy_paste() -> Result {
let list = gsettings(&["get", "org.gnome.Terminal.ProfilesList", "list"])?;
let mut changed = 0u32;
for uuid in parse_gsettings_array(&list) {
@@ -297,26 +300,116 @@ mod imp {
);
let copy = gsettings(&["get", &schema, "copy"]).unwrap_or_default();
let paste = gsettings(&["get", &schema, "paste"]).unwrap_or_default();
- let needs_copy = bindings_conflict(©, TERMINAL_SHIFT_COPY)
- || bindings_conflict(©, "C");
- let needs_paste = bindings_conflict(&paste, TERMINAL_SHIFT_PASTE)
- || bindings_conflict(&paste, "V");
+ let needs_copy = bindings_conflict(©, TERMINAL_BROKEN_COPY)
+ || bindings_conflict(©, "C");
+ let needs_paste = bindings_conflict(&paste, TERMINAL_BROKEN_PASTE)
+ || bindings_conflict(&paste, "V");
if needs_copy || needs_paste {
if needs_copy {
- gsettings_set(&["set", &schema, "copy", TERMINAL_STD_COPY])?;
+ gsettings_set(&["set", &schema, "copy", TERMINAL_SHIFT_COPY])?;
}
if needs_paste {
- gsettings_set(&["set", &schema, "paste", TERMINAL_STD_PASTE])?;
+ gsettings_set(&["set", &schema, "paste", TERMINAL_SHIFT_PASTE])?;
}
changed += 1;
tracing::info!(
- "Terminal profile {uuid}: copy/paste moved to Ctrl+C / Ctrl+V (frees Ctrl+Shift+C/V for Inspector)"
+ "Terminal profile {uuid}: restored copy/paste to Ctrl+Shift+C / Ctrl+Shift+V"
);
}
}
Ok(changed)
}
+ /// After restoring Terminal keys, move any Inspector shortcut that still
+ /// collides with Terminal copy/paste (Ctrl+Shift+C/V) to the next free preset.
+ pub fn restore_terminal_and_fix_shortcut_conflicts(db: &DbHandle) -> Result<()> {
+ let restored = restore_terminal_copy_paste()?;
+ if restored > 0 {
+ tracing::info!(
+ "Restored {restored} GNOME Terminal profile(s) — Ctrl+Shift+C/V is copy/paste again"
+ );
+ }
+
+ let desktop = detect();
+ let Some((list_key, list_schema, custom_schema, path_prefix, id_prefix)) =
+ gnome_family_config(desktop)
+ else {
+ return Ok(());
+ };
+
+ let terminal_occupied = collect_terminal_bindings()?;
+ let installed = read_our_installed_bindings(
+ list_schema,
+ list_key,
+ custom_schema,
+ path_prefix,
+ id_prefix,
+ )?;
+ if installed.is_empty() {
+ return Ok(());
+ }
+
+ let mut occupied =
+ collect_custom_shortcut_bindings(list_schema, list_key, custom_schema, id_prefix)?;
+ occupied.extend(terminal_occupied.iter().cloned());
+
+ let mut reserved = HashSet::new();
+ let mut resolved: Vec<(String, String)> = Vec::new();
+ let mut changed = false;
+
+ for spec in SHORTCUTS {
+ let current = installed
+ .iter()
+ .find(|(id, _)| id == spec.id)
+ .map(|(_, b)| b.clone())
+ .unwrap_or_else(|| spec.binding_candidates[0].to_string());
+
+ let norm = normalize_binding(¤t);
+ let collides = terminal_occupied.contains(&norm);
+
+ let chosen = if collides || norm.is_empty() {
+ pick_binding(spec.binding_candidates, &occupied, &reserved).with_context(|| {
+ format!("no free binding for {} after Terminal restore", spec.name)
+ })?
+ } else {
+ current.clone()
+ };
+
+ let chosen_norm = normalize_binding(&chosen);
+ if chosen_norm != norm {
+ changed = true;
+ tracing::info!(
+ "{} moved {} → {} (Terminal reserves Ctrl+Shift+C/V)",
+ spec.name,
+ binding_label(¤t),
+ binding_label(&chosen)
+ );
+ }
+
+ if !chosen_norm.is_empty() {
+ reserved.insert(chosen_norm.clone());
+ occupied.insert(chosen_norm);
+ }
+
+ let path = format!("{path_prefix}{id_prefix}{}/", spec.id);
+ let schema = format!("{custom_schema}:{path}");
+ gsettings_set(&["set", &schema, "binding", &chosen])?;
+ resolved.push((spec.id.to_string(), chosen));
+ }
+
+ if changed {
+ let summary: String = resolved
+ .iter()
+ .map(|(id, b)| format!("{id}={b}"))
+ .collect::>()
+ .join(",");
+ settings::set(db, SETTINGS_BINDINGS_KEY, &summary)?;
+ tracing::info!("Updated Inspector desktop shortcuts to avoid Terminal copy/paste");
+ }
+
+ Ok(())
+ }
+
fn pick_binding(
candidates: &[&str],
occupied: &HashSet,
@@ -392,6 +485,19 @@ mod imp {
"Space" => key = "space".into(),
"Tab" => key = "Tab".into(),
"Escape" => key = "Escape".into(),
+ "Backquote" => key = "grave".into(),
+ "Less" => key = "less".into(),
+ "Minus" => key = "minus".into(),
+ "Equal" => key = "equal".into(),
+ "BracketLeft" => key = "bracketleft".into(),
+ "BracketRight" => key = "bracketright".into(),
+ "Backslash" => key = "backslash".into(),
+ "Semicolon" => key = "semicolon".into(),
+ "Quote" => key = "apostrophe".into(),
+ "Comma" => key = "comma".into(),
+ "Period" => key = "period".into(),
+ "Slash" => key = "slash".into(),
+ "IntlBackslash" => key = "section".into(),
"F1" | "F2" | "F3" | "F4" | "F5" | "F6" | "F7" | "F8" | "F9" | "F10" | "F11"
| "F12" => key = p.to_lowercase(),
s if s.len() == 1 => key = s.to_lowercase(),
@@ -407,7 +513,7 @@ mod imp {
Ok(format!("{}{}", mods.concat(), key))
}
- fn count_terminal_profiles_needing_fix() -> Result {
+ fn count_terminal_profiles_needing_restore() -> Result {
let list = gsettings(&["get", "org.gnome.Terminal.ProfilesList", "list"])?;
let mut count = 0u32;
for uuid in parse_gsettings_array(&list) {
@@ -416,11 +522,11 @@ mod imp {
);
let copy = gsettings(&["get", &schema, "copy"]).unwrap_or_default();
let paste = gsettings(&["get", &schema, "paste"]).unwrap_or_default();
- let needs_copy = bindings_conflict(©, TERMINAL_SHIFT_COPY)
- || bindings_conflict(©, "C");
- let needs_paste = bindings_conflict(&paste, TERMINAL_SHIFT_PASTE)
- || bindings_conflict(&paste, "V");
- if needs_copy || needs_paste {
+ let broken_copy = bindings_conflict(©, TERMINAL_BROKEN_COPY)
+ || bindings_conflict(©, "C");
+ let broken_paste = bindings_conflict(&paste, TERMINAL_BROKEN_PASTE)
+ || bindings_conflict(&paste, "V");
+ if broken_copy || broken_paste {
count += 1;
}
}
@@ -581,7 +687,7 @@ mod imp {
return Ok(base());
};
- let terminal_profiles_to_fix = count_terminal_profiles_needing_fix()?;
+ let terminal_profiles_to_fix = count_terminal_profiles_needing_restore()?;
let occupied = collect_occupied_for_scan(list_schema, list_key, custom_schema, id_prefix)?;
let installed: std::collections::HashMap = read_our_installed_bindings(
@@ -657,8 +763,9 @@ mod imp {
rows,
message: if terminal_profiles_to_fix > 0 {
Some(format!(
- "{terminal_profiles_to_fix} GNOME Terminal profile(s) still use Ctrl+Shift+C/V — \
- saving will move them to Ctrl+C / Ctrl+V automatically."
+ "{terminal_profiles_to_fix} GNOME Terminal profile(s) still have broken Ctrl+C/V copy/paste \
+ from an older Inspector build — saving will restore Ctrl+Shift+C/V and move Inspector \
+ shortcuts to free fallbacks."
))
} else {
None
@@ -723,10 +830,7 @@ mod imp {
anyhow::bail!("desktop does not support gsettings shortcut configuration");
};
- let term_changed = reconcile_terminal_copy_paste()?;
- if term_changed > 0 {
- tracing::info!("Adjusted {term_changed} GNOME Terminal profile(s) to Ctrl+C / Ctrl+V");
- }
+ restore_terminal_copy_paste()?;
let mut occupied =
collect_custom_shortcut_bindings(list_schema, list_key, custom_schema, id_prefix)?;
@@ -787,12 +891,7 @@ mod imp {
path_prefix: &str,
id_prefix: &str,
) -> Result<()> {
- let term_changed = reconcile_terminal_copy_paste()?;
- if term_changed > 0 {
- tracing::info!(
- "Adjusted {term_changed} GNOME Terminal profile(s) to Ctrl+C / Ctrl+V after shortcut conflict scan"
- );
- }
+ restore_terminal_copy_paste()?;
let mut occupied =
collect_custom_shortcut_bindings(list_schema, list_key, custom_schema, id_prefix)?;
@@ -917,6 +1016,96 @@ mod imp {
Ok(())
}
+ const EXPANDER_SHORTCUT_ID: &str = "expander";
+ const EXPANDER_SHORTCUT_NAME: &str = "Inspector Rust — Text expander";
+
+ /// True when the text-expander hotkey is registered via gsettings (GNOME/Cinnamon
+ /// Wayland), not Tauri's in-app global shortcut.
+ pub fn expander_hotkey_needs_gsettings() -> bool {
+ !matches!(detect(), LinuxDesktop::X11 | LinuxDesktop::Kde)
+ }
+
+ /// Register (or remove) the text-expander hotkey in GNOME/Cinnamon gsettings.
+ /// On Wayland, Tauri global shortcuts do not fire — this is the only path that
+ /// makes the expander hotkey work without manual GNOME Settings setup.
+ pub fn sync_expander_shortcut(_db: &DbHandle, enabled: bool, hotkey: &str) -> Result<()> {
+ let desktop = detect();
+ let Some((list_key, list_schema, custom_schema, path_prefix, id_prefix)) =
+ gnome_family_config(desktop)
+ else {
+ return Ok(());
+ };
+
+ let path = format!("{path_prefix}{id_prefix}{EXPANDER_SHORTCUT_ID}/");
+ let mut paths = parse_gsettings_array(&gsettings(&["get", list_schema, list_key])?);
+
+ if !enabled {
+ if paths.contains(&path) {
+ paths.retain(|p| p != &path);
+ gsettings_set(&[
+ "set",
+ list_schema,
+ list_key,
+ &format_gsettings_array(&paths),
+ ])?;
+ tracing::info!("Removed desktop shortcut for text expander");
+ }
+ return Ok(());
+ }
+
+ let gsettings_binding = web_hotkey_to_gsettings(hotkey).map_err(anyhow::Error::msg)?;
+ let norm = normalize_binding(&gsettings_binding);
+ if norm.is_empty() {
+ anyhow::bail!("invalid expander hotkey");
+ }
+
+ let mut occupied =
+ collect_custom_shortcut_bindings(list_schema, list_key, custom_schema, id_prefix)?;
+ occupied.extend(collect_terminal_bindings()?);
+
+ let schema = format!("{custom_schema}:{path}");
+ let current_norm = if paths.contains(&path) {
+ gsettings(&["get", &schema, "binding"])
+ .ok()
+ .map(|b| normalize_binding(&b))
+ } else {
+ None
+ };
+
+ if occupied.contains(&norm) && current_norm.as_ref() != Some(&norm) {
+ anyhow::bail!(
+ "{} is already used — pick another hotkey (try Alt+1)",
+ binding_label(&gsettings_binding)
+ );
+ }
+
+ let cmd = inspector_command();
+ if !paths.contains(&path) {
+ paths.push(path.clone());
+ }
+ gsettings_set(&["set", &schema, "name", EXPANDER_SHORTCUT_NAME])?;
+ gsettings_set(&[
+ "set",
+ &schema,
+ "command",
+ &format!("{cmd} --expand-at-cursor"),
+ ])?;
+ gsettings_set(&["set", &schema, "binding", &gsettings_binding])?;
+ gsettings_set(&[
+ "set",
+ list_schema,
+ list_key,
+ &format_gsettings_array(&paths),
+ ])?;
+ tracing::info!(
+ "{} → {} ({})",
+ EXPANDER_SHORTCUT_NAME,
+ binding_label(&gsettings_binding),
+ gsettings_binding
+ );
+ Ok(())
+ }
+
#[cfg(test)]
mod tests {
use super::{bindings_conflict, normalize_binding, pick_binding};
@@ -948,13 +1137,28 @@ mod imp {
Some("c")
);
}
+
+ #[test]
+ fn web_hotkey_converts_backquote() {
+ use super::web_hotkey_to_gsettings;
+ assert_eq!(
+ web_hotkey_to_gsettings("Ctrl+Backquote").unwrap(),
+ "grave"
+ );
+ assert_eq!(web_hotkey_to_gsettings("Alt+Digit1").unwrap(), "1");
+ assert_eq!(
+ web_hotkey_to_gsettings("Ctrl+Less").unwrap(),
+ "less"
+ );
+ }
}
}
#[cfg(target_os = "linux")]
pub use imp::{
- apply_shortcut_setup, force_reinstall, scan_shortcut_setup, try_auto_install,
- web_hotkey_to_gsettings, ShortcutCandidate, ShortcutRow, ShortcutSetupScan,
+ apply_shortcut_setup, expander_hotkey_needs_gsettings, force_reinstall,
+ restore_terminal_and_fix_shortcut_conflicts, scan_shortcut_setup, sync_expander_shortcut,
+ try_auto_install, web_hotkey_to_gsettings, ShortcutSetupScan,
};
#[cfg(not(target_os = "linux"))]
@@ -968,6 +1172,21 @@ pub fn detect() -> LinuxDesktop {
LinuxDesktop::Other
}
+#[cfg(not(target_os = "linux"))]
+pub fn expander_hotkey_needs_gsettings() -> bool {
+ false
+}
+
+#[cfg(not(target_os = "linux"))]
+pub fn restore_terminal_and_fix_shortcut_conflicts(_db: &DbHandle) -> anyhow::Result<()> {
+ Ok(())
+}
+
+#[cfg(not(target_os = "linux"))]
+pub fn sync_expander_shortcut(_db: &DbHandle, _enabled: bool, _hotkey: &str) -> anyhow::Result<()> {
+ Ok(())
+}
+
#[cfg(not(target_os = "linux"))]
pub fn try_auto_install(_db: &DbHandle) -> anyhow::Result<()> {
Ok(())
diff --git a/core/rust-lib/src/expander.rs b/core/rust-lib/src/expander.rs
index 3d032ad..f027d14 100644
--- a/core/rust-lib/src/expander.rs
+++ b/core/rust-lib/src/expander.rs
@@ -122,11 +122,15 @@ pub fn accessibility_granted() -> bool {
}
/// Whether the OS-level synthetic-input permission is active.
-#[cfg(not(target_os = "macos"))]
+#[cfg(target_os = "linux")]
+pub fn accessibility_granted() -> bool {
+ // Always attempt AT-SPI first; fall back to the clipboard path when it
+ // returns None (same policy as Windows UI Automation).
+ true
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "linux")))]
pub fn accessibility_granted() -> bool {
- // Other platforms either don't gate synthetic input behind a TCC-style
- // permission (Windows, X11) or do so through an entirely different
- // mechanism (Wayland portals). Optimistic default.
true
}
@@ -343,6 +347,9 @@ pub const KEY_ENABLED: &str = "expander.enabled";
/// migrated to [`DEFAULT_HOTKEY`]. Prevents re-migrating a value the user
/// deliberately set back to `Alt+Backquote` afterwards.
pub const KEY_HOTKEY_MIGRATED: &str = "expander.hotkey_migrated_v0_12";
+/// One-shot flag: on Linux Wayland, migrate layout-sensitive hotkeys
+/// (`Ctrl+Backquote`, German `<` key confusion) to [`DEFAULT_HOTKEY`].
+pub const KEY_HOTKEY_MIGRATED_LINUX: &str = "expander.hotkey_migrated_linux_v1";
/// Default hotkey when no setting has ever been written. `Alt + Digit1`
/// (the `1` row key, **not** the numpad) is layout-stable on every
@@ -462,7 +469,9 @@ pub struct DirectSlot {
/// will let the user re-add them) rather than a hard error.
pub fn get_direct_slots(db: &DbHandle) -> Result> {
match crate::settings::get(db, KEY_DIRECT_SLOTS)? {
- Some(json) if !json.trim().is_empty() => Ok(serde_json::from_str(&json).unwrap_or_default()),
+ Some(json) if !json.trim().is_empty() => {
+ Ok(serde_json::from_str(&json).unwrap_or_default())
+ }
_ => Ok(Vec::new()),
}
}
@@ -606,6 +615,52 @@ pub fn migrate_legacy_default(db: &DbHandle) -> String {
upgraded
}
+/// Linux Wayland hotkeys are registered via GNOME gsettings, not Tauri.
+/// Keys like `Ctrl+Backquote` map to `grave`, which is **not**
+/// the German `<` key (`less`) — the shortcut looks armed but never fires.
+/// Migrate known-broken combos once to the layout-stable default.
+#[cfg(target_os = "linux")]
+pub fn migrate_linux_wayland_hotkey(db: &DbHandle, stored: String) -> String {
+ use crate::desktop_shortcuts::expander_hotkey_needs_gsettings;
+ use crate::settings;
+
+ if settings::get_bool(db, KEY_HOTKEY_MIGRATED_LINUX, false).unwrap_or(false) {
+ return stored;
+ }
+
+ if !expander_hotkey_needs_gsettings() {
+ let _ = settings::set(db, KEY_HOTKEY_MIGRATED_LINUX, "true");
+ return stored;
+ }
+
+ const BROKEN: &[&str] = &[
+ "Ctrl+Backquote",
+ "Control+Backquote",
+ "Alt+Backquote",
+ "Ctrl+IntlBackslash",
+ "Alt+IntlBackslash",
+ ];
+
+ let upgraded = if BROKEN.iter().any(|b| stored.eq_ignore_ascii_case(b)) {
+ let _ = settings::set(db, KEY_HOTKEY, DEFAULT_HOTKEY);
+ tracing::info!(
+ "Linux Wayland: migrated expander hotkey {stored} → {DEFAULT_HOTKEY} \
+ (Backquote/grave does not match the German < key in gsettings; use Alt+1)"
+ );
+ DEFAULT_HOTKEY.to_string()
+ } else {
+ stored
+ };
+
+ let _ = settings::set(db, KEY_HOTKEY_MIGRATED_LINUX, "true");
+ upgraded
+}
+
+#[cfg(not(target_os = "linux"))]
+pub fn migrate_linux_wayland_hotkey(_db: &DbHandle, stored: String) -> String {
+ stored
+}
+
/// Diagnostic outcome of an expand-cycle attempt: what got captured,
/// whether it matched a snippet, and a preview of what would be pasted.
/// Used by the Settings panel's "Test now" button so the user can see
@@ -695,12 +750,7 @@ pub fn diagnose_at_cursor(db: &DbHandle) -> Result {
if !captured.is_empty() {
if let Some(snippet) = snippets::find_by_exact_abbreviation(db, &captured)? {
// First 80 chars of the body, single-line preview.
- let preview: String = snippet
- .body
- .replace('\n', " ")
- .chars()
- .take(80)
- .collect();
+ let preview: String = snippet.body.replace('\n', " ").chars().take(80).collect();
result.matched_abbreviation = Some(snippet.abbreviation);
result.paste_preview = Some(preview);
}
@@ -777,7 +827,14 @@ pub fn expand_at_cursor(
if let Some(snippet) = snippets::find_by_exact_abbreviation(db, &word)? {
// Try the in-place replace via the same accessibility layer.
match access.try_replace_word_before_cursor(&snippet.body) {
- Ok(ReplaceOutcome::Replaced) => return Ok(()),
+ Ok(ReplaceOutcome::Replaced) => {
+ tracing::info!(
+ "expander: matched snippet {:?} via {:?}",
+ snippet.abbreviation,
+ native_path()
+ );
+ return Ok(());
+ }
Ok(ReplaceOutcome::SelectionActive) => {
// The AX layer *selected* the abbreviation but the
// in-place text set was a no-op — the typical
@@ -893,6 +950,10 @@ fn expand_via_clipboard(
trim_abbreviation(&abbr_raw)
};
if abbr.is_empty() {
+ tracing::info!(
+ "expander: no abbreviation captured before cursor (clipboard path empty — \
+ keystroke synthesis may not reach this app on Wayland, or nothing to select)"
+ );
restore_clipboard(saved.as_deref());
return Ok(());
}
@@ -903,11 +964,16 @@ fn expand_via_clipboard(
} else {
let hit = snippets::find_by_exact_abbreviation(db, abbr)?;
let Some(snippet) = hit else {
+ tracing::info!("expander: captured {abbr:?} but no snippet matches that abbreviation");
// Selection stays highlighted in the source app — visual cue
// that nothing matched. Restore clipboard before bailing.
restore_clipboard(saved.as_deref());
return Ok(());
};
+ tracing::info!(
+ "expander: matched snippet {:?} via clipboard path",
+ snippet.abbreviation
+ );
snippet.body
};
@@ -921,6 +987,10 @@ fn expand_via_clipboard(
write_clipboard_text(&body)?;
thread::sleep(Duration::from_millis(50));
send_paste()?;
+ tracing::info!(
+ "expander: pasted snippet body ({body_len} chars)",
+ body_len = body.len()
+ );
// 6) Background restore (v0.35.0+). Same shape as
// `paste_over_selection`: return immediately after paste, then
@@ -1027,8 +1097,7 @@ fn read_clipboard_text() -> Option {
}
fn write_clipboard_text(text: &str) -> Result<()> {
- let ctx = ClipboardContext::new()
- .map_err(|e| anyhow!("clipboard ctx init failed: {e:?}"))?;
+ let ctx = ClipboardContext::new().map_err(|e| anyhow!("clipboard ctx init failed: {e:?}"))?;
ctx.set_text(text.to_string())
.map_err(|e| anyhow!("set_text failed: {e:?}"))?;
Ok(())
diff --git a/core/rust-lib/src/lib.rs b/core/rust-lib/src/lib.rs
index 31d464a..361430e 100644
--- a/core/rust-lib/src/lib.rs
+++ b/core/rust-lib/src/lib.rs
@@ -15,12 +15,14 @@ mod desktop_shortcuts;
mod expander;
mod hotkey;
mod image_ops;
+mod input_lock;
+#[cfg(target_os = "linux")]
+mod linux_portal;
mod models;
mod notes;
mod ocr;
mod paste;
mod recolor;
-mod input_lock;
mod region_picker;
mod screen_picker;
mod screen_recording;
@@ -168,6 +170,9 @@ pub fn run(context: tauri::Context) {
let enabled = settings::get_bool(&db_handle, expander::KEY_ENABLED, false)
.unwrap_or(false);
let hotkey_str = expander::migrate_legacy_default(&db_handle);
+ #[cfg(target_os = "linux")]
+ let hotkey_str =
+ expander::migrate_linux_wayland_hotkey(&db_handle, hotkey_str);
let state = app
.state::();
if let Err(e) = hotkey::register_expander(
@@ -179,6 +184,17 @@ pub fn run(context: tauri::Context) {
tracing::warn!("expander hotkey register failed at startup: {e:#}");
}
+ #[cfg(target_os = "linux")]
+ if enabled {
+ if let Err(e) = desktop_shortcuts::sync_expander_shortcut(
+ &db_handle,
+ true,
+ &hotkey_str,
+ ) {
+ tracing::warn!("expander desktop shortcut sync at startup: {e:#}");
+ }
+ }
+
// Direct hotkey→snippet slots (independent of the
// abbreviation expander; the only mode that works in
// terminals, since it pastes without reading anything).
@@ -215,7 +231,21 @@ pub fn run(context: tauri::Context) {
#[cfg(target_os = "linux")]
{
+ if let Err(e) = pollster::block_on(atspi::connection::set_session_accessibility(
+ true,
+ )) {
+ tracing::warn!("AT-SPI set_session_accessibility(true): {e}");
+ } else {
+ tracing::info!(
+ "AT-SPI session accessibility enabled (text expander uses focused field, not clipboard)"
+ );
+ }
cli_dispatch::log_wayland_shortcut_hint();
+ if let Err(e) =
+ desktop_shortcuts::restore_terminal_and_fix_shortcut_conflicts(&db_handle)
+ {
+ tracing::warn!("restore Terminal / fix shortcut conflicts: {e:#}");
+ }
if let Err(e) = desktop_shortcuts::try_auto_install(&db_handle) {
tracing::warn!("desktop shortcut auto-setup: {e:#}");
}
diff --git a/core/rust-lib/src/linux_portal.rs b/core/rust-lib/src/linux_portal.rs
new file mode 100644
index 0000000..08ef21d
--- /dev/null
+++ b/core/rust-lib/src/linux_portal.rs
@@ -0,0 +1,93 @@
+//! xdg-desktop-portal integration for GNOME/Cinnamon on Wayland.
+//!
+//! `grim` + `slurp` work on wlroots compositors (Sway, Hyprland) but **slurp
+//! hangs with no UI on GNOME** — region capture must go through the portal
+//! (same pipeline as Ubuntu's built-in screenshot tool).
+
+use anyhow::{anyhow, Context, Result};
+use ashpd::desktop::ResponseError;
+use ashpd::PortalError;
+use std::path::PathBuf;
+
+pub const ERR_PORTAL_CANCELLED: &str = "portal_cancelled";
+
+/// GNOME/Cinnamon Wayland sessions should use the portal, not slurp.
+pub fn prefer_portal_capture() -> bool {
+ if std::env::var_os("WAYLAND_DISPLAY").is_none() {
+ return false;
+ }
+ let desktop = std::env::var("XDG_CURRENT_DESKTOP")
+ .unwrap_or_default()
+ .to_uppercase();
+ desktop.contains("GNOME") || desktop.contains("CINNAMON") || desktop.contains("UBUNTU")
+}
+
+fn uri_to_path(uri: &url::Url) -> Result {
+ if uri.scheme() == "file" {
+ return uri
+ .to_file_path()
+ .map_err(|_| anyhow!("portal returned non-local file URI: {uri}"));
+ }
+ Err(anyhow!("portal returned unsupported URI scheme: {uri}"))
+}
+
+fn portal_cancelled(err: &ashpd::Error) -> bool {
+ matches!(
+ err,
+ ashpd::Error::Response(ResponseError::Cancelled)
+ | ashpd::Error::Portal(PortalError::Cancelled(_))
+ )
+}
+
+/// Interactive region/window/screen capture via org.freedesktop.portal.Screenshot.
+pub fn capture_region() -> Result> {
+ use ashpd::desktop::screenshot::Screenshot;
+
+ let response = pollster::block_on(async {
+ Screenshot::request()
+ .interactive(true)
+ .modal(true)
+ .send()
+ .await?
+ .response()
+ })
+ .map_err(|e| {
+ if portal_cancelled(&e) {
+ anyhow!(ERR_PORTAL_CANCELLED)
+ } else {
+ anyhow!("portal screenshot: {e}")
+ }
+ })?;
+
+ let path = uri_to_path(response.uri())?;
+ let bytes = std::fs::read(&path)
+ .with_context(|| format!("read portal screenshot {}", path.display()))?;
+ let _ = std::fs::remove_file(&path);
+ if bytes.is_empty() {
+ return Err(anyhow!(ERR_PORTAL_CANCELLED));
+ }
+ Ok(bytes)
+}
+
+/// System eyedropper via org.freedesktop.portal.Screenshot.PickColor.
+pub fn pick_color() -> Result> {
+ use ashpd::desktop::Color;
+
+ match pollster::block_on(async { Color::pick().send().await?.response() }) {
+ Ok(c) => {
+ let hex = format!(
+ "#{:02x}{:02x}{:02x}",
+ c.red().round() as u8,
+ c.green().round() as u8,
+ c.blue().round() as u8,
+ );
+ Ok(Some(hex))
+ }
+ Err(e) if portal_cancelled(&e) => Ok(None),
+ Err(e) => Err(anyhow!("portal pick color: {e}")),
+ }
+}
+
+pub fn is_portal_cancelled(err: &anyhow::Error) -> bool {
+ err.to_string() == ERR_PORTAL_CANCELLED
+}
diff --git a/core/rust-lib/src/region_picker.rs b/core/rust-lib/src/region_picker.rs
index 2d3b550..4b01b14 100644
--- a/core/rust-lib/src/region_picker.rs
+++ b/core/rust-lib/src/region_picker.rs
@@ -384,13 +384,26 @@ mod win_impl {
}
}
-/// Linux: Wayland (`grim` + `slurp`) when available, else X11 (`scrot -s`).
+/// Linux: xdg-desktop-portal on GNOME/Cinnamon Wayland, else grim+slurp
+/// (wlroots), else X11 scrot.
#[cfg(target_os = "linux")]
fn capture_impl() -> Result> {
use anyhow::Context;
use chrono::Utc;
use std::process::Command;
+ if crate::linux_portal::prefer_portal_capture() {
+ match crate::linux_portal::capture_region() {
+ Ok(bytes) => return Ok(bytes),
+ Err(e) if crate::linux_portal::is_portal_cancelled(&e) => {
+ return Err(Cancelled.into());
+ }
+ Err(e) => {
+ tracing::warn!("portal region capture failed ({e:#}); trying grim+slurp fallback");
+ }
+ }
+ }
+
let tmp = std::env::temp_dir().join(format!(
"inspector-rust-region-{}.png",
Utc::now().timestamp_millis()
@@ -425,9 +438,10 @@ fn capture_impl() -> Result> {
.context("spawn scrot")?
} else {
anyhow::bail!(
- "region capture needs scrot (X11) or grim+slurp (Wayland). \
+ "region capture needs xdg-desktop-portal (GNOME Wayland), scrot (X11), \
+ or grim+slurp (wlroots Wayland). \
Install: sudo apt install scrot # X11\n\
- or: sudo apt install grim slurp # Wayland"
+ or: sudo apt install grim slurp # Sway/Hyprland"
);
};
diff --git a/core/rust-lib/src/text_field/linux.rs b/core/rust-lib/src/text_field/linux.rs
new file mode 100644
index 0000000..9255bde
--- /dev/null
+++ b/core/rust-lib/src/text_field/linux.rs
@@ -0,0 +1,186 @@
+//! Linux AT-SPI2 implementation of [`FieldAccess`].
+//!
+//! On GNOME Wayland, enigo/XTest often injects keystrokes into the wrong
+//! window (e.g. the XWayland terminal) while the user types in a native
+//! Wayland app (WhatsApp, Firefox, gedit). AT-SPI reads the actually focused
+//! text field over D-Bus — no synthetic keys, no clipboard roundtrip.
+
+use anyhow::{anyhow, Result};
+use atspi::connection::{set_session_accessibility, AccessibilityConnection};
+use atspi::proxy::accessible::AccessibleProxy;
+use atspi::proxy::proxy_ext::ProxyExt;
+use atspi::zbus::proxy::CacheProperties;
+use atspi::{Granularity, ObjectRefOwned, Role, State};
+use std::sync::atomic::{AtomicBool, Ordering};
+
+use super::{trim_word, FieldAccess, ReplaceOutcome};
+
+const MAX_VISIT_NODES: usize = 800;
+
+static A11Y_ENABLED: AtomicBool = AtomicBool::new(false);
+
+fn ensure_a11y_enabled() {
+ if A11Y_ENABLED.swap(true, Ordering::Relaxed) {
+ return;
+ }
+ if let Err(e) = pollster::block_on(set_session_accessibility(true)) {
+ tracing::warn!("AT-SPI set_session_accessibility(true): {e}");
+ }
+}
+
+async fn open_connection() -> Result {
+ ensure_a11y_enabled();
+ AccessibilityConnection::new()
+ .await
+ .map_err(|e| anyhow!("AT-SPI connection failed: {e}"))
+}
+
+async fn proxy_for<'a>(
+ conn: &'a AccessibilityConnection,
+ obj: &ObjectRefOwned,
+) -> Result> {
+ let name = obj
+ .name()
+ .ok_or_else(|| anyhow!("AT-SPI object missing bus name"))?;
+ AccessibleProxy::builder(conn.connection())
+ .destination(name.to_owned())?
+ .path(obj.path())?
+ .cache_properties(CacheProperties::No)
+ .build()
+ .await
+ .map_err(|e| anyhow!("AccessibleProxy build: {e}"))
+}
+
+async fn read_word_async() -> Result> {
+ let conn = open_connection().await?;
+ let root = conn
+ .root_accessible_on_registry()
+ .await
+ .map_err(|e| anyhow!("AT-SPI registry root: {e}"))?;
+ let apps = root
+ .get_children()
+ .await
+ .map_err(|e| anyhow!("AT-SPI get_children: {e}"))?;
+ let mut stack: Vec = apps;
+ let mut visited = 0usize;
+
+ while let Some(obj) = stack.pop() {
+ if visited >= MAX_VISIT_NODES {
+ break;
+ }
+ visited += 1;
+ let node = proxy_for(&conn, &obj).await?;
+
+ if let Ok(states) = node.get_state().await {
+ if states.contains(State::Focused) {
+ if let Ok(role) = node.get_role().await {
+ if role == Role::PasswordText {
+ continue;
+ }
+ }
+ if let Ok(proxies) = node.proxies().await {
+ if let Ok(text) = proxies.text().await {
+ let caret = text
+ .caret_offset()
+ .await
+ .map_err(|e| anyhow!("AT-SPI caret_offset: {e}"))?;
+ let gran = Granularity::Word as u32;
+ let (word, _start, _end) =
+ text.get_text_before_offset(caret, gran)
+ .await
+ .map_err(|e| anyhow!("AT-SPI get_text_before_offset: {e}"))?;
+ let trimmed = trim_word(&word);
+ if !trimmed.is_empty() {
+ tracing::info!(
+ "AT-SPI: read word before cursor at {}: {trimmed:?}",
+ node.inner().path()
+ );
+ return Ok(Some(trimmed.to_string()));
+ }
+ }
+ }
+ }
+ }
+
+ if let Ok(children) = node.get_children().await {
+ stack.extend(children);
+ }
+ }
+
+ tracing::debug!("AT-SPI: no focused text field found");
+ Ok(None)
+}
+
+async fn replace_word_async(replacement: &str) -> Result {
+ let conn = open_connection().await?;
+ let root = conn
+ .root_accessible_on_registry()
+ .await
+ .map_err(|e| anyhow!("AT-SPI registry root: {e}"))?;
+ let apps = root.get_children().await.map_err(|e| anyhow!("{e}"))?;
+ let mut stack: Vec = apps;
+ let mut visited = 0usize;
+
+ while let Some(obj) = stack.pop() {
+ if visited >= MAX_VISIT_NODES {
+ break;
+ }
+ visited += 1;
+ let node = proxy_for(&conn, &obj).await?;
+
+ if let Ok(states) = node.get_state().await {
+ if states.contains(State::Focused) {
+ if let Ok(role) = node.get_role().await {
+ if role == Role::PasswordText {
+ continue;
+ }
+ }
+ if let Ok(proxies) = node.proxies().await {
+ if let Ok(text) = proxies.text().await {
+ let caret = text.caret_offset().await?;
+ let gran = Granularity::Word as u32;
+ let (_word, start, _end) = text.get_text_before_offset(caret, gran).await?;
+ if let Ok(ed) = proxies.editable_text().await {
+ let _ = ed.delete_text(start, caret).await;
+ let len = replacement.chars().count() as i32;
+ if ed
+ .insert_text(start, replacement, len)
+ .await
+ .unwrap_or(false)
+ {
+ tracing::info!("AT-SPI: replaced word via EditableText");
+ return Ok(ReplaceOutcome::Replaced);
+ }
+ }
+ let _ = text.set_selection(0, start, caret).await;
+ tracing::info!("AT-SPI: selected abbreviation; caller should paste");
+ return Ok(ReplaceOutcome::SelectionActive);
+ }
+ }
+ }
+ }
+
+ if let Ok(children) = node.get_children().await {
+ stack.extend(children);
+ }
+ }
+
+ Ok(ReplaceOutcome::Unsupported)
+}
+
+pub struct AtspiFieldAccess;
+
+impl FieldAccess for AtspiFieldAccess {
+ fn read_word_before_cursor(&self) -> Result> {
+ pollster::block_on(read_word_async())
+ }
+
+ fn try_replace_word_before_cursor(&self, replacement: &str) -> Result {
+ pollster::block_on(replace_word_async(replacement))
+ }
+}
+
+/// Whether AT-SPI registry is reachable on the session bus.
+pub fn atspi_available() -> bool {
+ pollster::block_on(async { open_connection().await.is_ok() })
+}
diff --git a/core/rust-lib/src/text_field/mod.rs b/core/rust-lib/src/text_field/mod.rs
index cc5ae10..9eb520c 100644
--- a/core/rust-lib/src/text_field/mod.rs
+++ b/core/rust-lib/src/text_field/mod.rs
@@ -102,7 +102,9 @@ pub enum CapturePath {
Ax,
/// Windows UI Automation succeeded.
Uia,
- /// Both AX/UIA returned None — fell back to the clipboard roundtrip.
+ /// Linux AT-SPI2 succeeded.
+ Atspi,
+ /// Native a11y returned None — fell back to the clipboard roundtrip.
Clipboard,
}
@@ -116,7 +118,11 @@ pub fn default_field_access() -> Box {
{
Box::new(windows::UiaFieldAccess)
}
- #[cfg(not(any(target_os = "macos", target_os = "windows")))]
+ #[cfg(target_os = "linux")]
+ {
+ Box::new(linux::AtspiFieldAccess)
+ }
+ #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
Box::new(stub::StubFieldAccess)
}
@@ -132,7 +138,11 @@ pub fn native_path() -> CapturePath {
{
CapturePath::Uia
}
- #[cfg(not(any(target_os = "macos", target_os = "windows")))]
+ #[cfg(target_os = "linux")]
+ {
+ CapturePath::Atspi
+ }
+ #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
CapturePath::Clipboard
}
@@ -177,7 +187,10 @@ pub mod macos;
#[cfg(target_os = "windows")]
pub mod windows;
-#[cfg(not(any(target_os = "macos", target_os = "windows")))]
+#[cfg(target_os = "linux")]
+pub mod linux;
+
+#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
pub mod stub {
use super::*;
diff --git a/linux/README.md b/linux/README.md
index 91815de..668829d 100644
--- a/linux/README.md
+++ b/linux/README.md
@@ -47,10 +47,10 @@ AppImage troubleshooting: `sudo apt install libfuse2`, then rebuild. In Cursor
| Global shortcuts + system tray | Yes (X11; Wayland may need compositor support) |
| Paste into focused app (`Ctrl+V` via enigo) | Yes |
| ML background cutout (ONNX) | Yes (offline) |
-| Region screenshot (`Ctrl+Shift+S`) | Yes with **scrot** (X11) or **grim+slurp** (Wayland) |
-| Screen OCR (`Ctrl+Shift+O`) | Requires **tesseract** + `tesseract-ocr-eng` (German optional: `tesseract-ocr-deu`) |
-| In-app eyedropper | Not yet (macOS/Windows only) |
-| Text expander in-place (AX/UIA) | Keystroke/clipboard fallback only |
+| Region screenshot (`Ctrl+Shift+S`) | Yes — **xdg-desktop-portal** on GNOME Wayland; **grim+slurp** on wlroots; **scrot** on X11 |
+| Screen OCR (`Ctrl+Shift+O`) | Same capture path as screenshot + **tesseract** (`tesseract-ocr-eng`; German: `tesseract-ocr-deu`) |
+| Color picker (`Ctrl+Shift+C`) | Yes on GNOME Wayland via **xdg-desktop-portal PickColor** (macOS/Windows use native loupe) |
+| Text expander hotkey | **Enable** in Settings → pick **Alt+1** preset → **Save & re-register**. On GNOME Wayland the hotkey is registered via gsettings (not in-app). Avoid `Ctrl+Backquote` on German keyboards — it maps to `grave`, not the `<` key (`less`). Expansion uses **AT-SPI** (reads the focused text field over D-Bus) — **not** synthetic Ctrl+Shift+← in the terminal. |
Data path: `~/.local/share/InspectorRust/history.db`
@@ -68,8 +68,10 @@ Recommended for full feature set:
```bash
sudo apt-get install -y scrot tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu
-# Wayland only:
+# wlroots Wayland (Sway/Hyprland) — not needed on GNOME (uses xdg-desktop-portal):
sudo apt-get install -y grim slurp
+# GNOME Wayland needs the portal (usually preinstalled):
+sudo apt-get install -y xdg-desktop-portal xdg-desktop-portal-gnome
```
## Desktop environment notes
@@ -89,8 +91,8 @@ Check under **Settings → Keyboard → Custom Shortcuts** (entries named “Ins
**Automatic conflict handling (v2):** On first start (and after profile upgrades) Inspector Rust:
1. Scans existing **custom shortcuts** and **GNOME Terminal** copy/paste bindings via `gsettings`.
-2. Moves Terminal off **Ctrl+Shift+C/V** to **Ctrl+C/V** when that would clash with Inspector’s color/popup shortcuts.
-3. Picks the first free binding per action (defaults: Ctrl+Shift+V/O/S/C; fallbacks e.g. Ctrl+Alt+… if occupied).
+2. **Never** changes Terminal — copy/paste stay **Ctrl+Shift+C/V**. If an older build wrongly set Ctrl+C/V, Inspector restores Terminal on startup.
+3. Picks the first free binding per action (defaults: Ctrl+Shift+V/O/S/C; fallbacks e.g. Ctrl+Alt+… when Terminal or other apps occupy a key).
No manual `ubuntu-terminal-copy-paste-ctrl-cv.sh` required.
@@ -106,7 +108,8 @@ bash scripts/install-linux.sh # runs --setup-shortcuts when the binary is on
- **X11**: No extra setup — built-in global shortcuts usually work.
- **KDE Plasma**: Not automated yet; bind shortcuts manually to the commands above.
-- **Wayland region capture**: Install `grim` and `slurp` (used when OCR/Screenshot runs from tray or CLI).
+- **Wayland region capture (GNOME)**: Uses **xdg-desktop-portal** (Ubuntu's native screenshot UI). Do **not** rely on `slurp` on GNOME — it hangs with no UI.
+- **Wayland region capture (Sway/Hyprland)**: Install `grim` + `slurp`.
- **Clipboard on Wayland**: If the log shows `ext-data-control` / `wlr-data-control` is missing, the app falls back to the X11 clipboard bridge. For full Wayland clipboard sync, use a compositor that supports those protocols, or run under an X11/XWayland session.
- **Autostart**: Uses the Tauri autostart plugin (typically `~/.config/autostart/`).
@@ -116,8 +119,13 @@ bash scripts/install-linux.sh # runs --setup-shortcuts when the binary is on
| -------- | ----- |
| `webkit2gtk` not found | Run `scripts/install-linux.sh` or install `libwebkit2gtk-4.1-dev` |
| `cargo` / edition errors | `rustup default stable` (need Rust ≥ 1.77) |
-| Region capture fails | Install `scrot` (X11) or `grim`+`slurp` (Wayland) |
+| Region capture fails / nothing on screen | GNOME: ensure `xdg-desktop-portal-gnome` is installed. Sway/Hyprland: install `grim`+`slurp`. X11: `scrot` |
| OCR shortcut errors | `sudo apt install tesseract-ocr tesseract-ocr-eng` (optional German: `tesseract-ocr-deu`) |
+| Color picker does nothing | Rebuild with latest Linux portal support; needs `xdg-desktop-portal-gnome` |
+| Text expander hotkey dead (Wayland) | Settings → **Enable** expander → pick hotkey (e.g. Alt+1) → **Save & re-register** — gsettings entry is created automatically |
+| Expander captures terminal scrollback / huge text | You tested with focus in the **terminal** — use WhatsApp, gedit, or Firefox. Log should show `AT-SPI: read word before cursor: "mfg"` not clipboard fallback |
+| Diagnose shows **clipboard** path | Focus a normal text field (not Terminal) before Diagnose; ensure `org.a11y.Bus` is running (`gsettings get org.gnome.desktop.a11y.interface toolkit-accessibility`) |
+| Diagnose closes Settings | **Expected** — the popup must hide so Inspector can read the field in the app behind it. Type your abbreviation in another app first, then click Diagnose |
| `Ctrl+Shift+V` does nothing (Wayland) | Restart app once (auto gsettings), or run `bash scripts/install-desktop-shortcuts.sh` after build |
| Conflict with copy/paste (`Ctrl+Shift+C/V`) | Automatic on install/first start; or Settings → Linux desktop shortcuts → **Auto-resolve all** |
| Tray icon missing | `libayatana-appindicator3-dev` + log out/in |