1use crate::engine::{capture, try_capture};
2#[cfg(all(feature = "linux-alpha", target_os = "linux"))]
3use crate::linux::try_selected_rtf_by_atspi;
4#[cfg(target_os = "macos")]
5use crate::macos::try_selected_rtf_by_ax;
6use crate::rich_clipboard::{RichClipboardPayload, RichClipboardReader, SystemRichClipboardReader};
7use crate::rich_convert::convert_to_markdown;
8use crate::rich_types::{
9 CaptureRichOptions, CaptureRichOutcome, CaptureRichSuccess, CapturedContent, ContentMetadata,
10 RichConversion, RichPayload, RichSource,
11};
12use crate::traits::{AppAdapter, AppProfileStore, CancelSignal, CapturePlatform};
13use crate::types::{ActiveApp, CaptureOutcome, CaptureTrace, TraceEvent, WouldBlock};
14#[cfg(all(feature = "windows-beta", target_os = "windows"))]
15use crate::windows::try_selected_rtf_by_uia;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18pub fn capture_rich(
19 platform: &impl CapturePlatform,
20 store: &impl AppProfileStore,
21 cancel: &impl CancelSignal,
22 adapters: &[&dyn AppAdapter],
23 options: &CaptureRichOptions,
24) -> CaptureRichOutcome {
25 let reader = SystemRichClipboardReader;
26 capture_rich_with_reader(platform, store, cancel, adapters, options, &reader)
27}
28
29pub fn try_capture_rich(
30 platform: &impl CapturePlatform,
31 store: &impl AppProfileStore,
32 cancel: &impl CancelSignal,
33 adapters: &[&dyn AppAdapter],
34 options: &CaptureRichOptions,
35) -> Result<CaptureRichOutcome, WouldBlock> {
36 let reader = SystemRichClipboardReader;
37 try_capture_rich_with_reader(platform, store, cancel, adapters, options, &reader)
38}
39
40fn capture_rich_with_reader(
41 platform: &impl CapturePlatform,
42 store: &impl AppProfileStore,
43 cancel: &impl CancelSignal,
44 adapters: &[&dyn AppAdapter],
45 options: &CaptureRichOptions,
46 reader: &impl RichClipboardReader,
47) -> CaptureRichOutcome {
48 capture_rich_with_reader_and_direct_reader(
49 platform,
50 store,
51 cancel,
52 adapters,
53 options,
54 reader,
55 &read_direct_rtf_for_current_selection,
56 )
57}
58
59fn capture_rich_with_reader_and_direct_reader(
60 platform: &impl CapturePlatform,
61 store: &impl AppProfileStore,
62 cancel: &impl CancelSignal,
63 adapters: &[&dyn AppAdapter],
64 options: &CaptureRichOptions,
65 reader: &impl RichClipboardReader,
66 direct_rtf_reader: &impl Fn() -> Option<String>,
67) -> CaptureRichOutcome {
68 let outcome = capture(platform, store, cancel, adapters, &options.base);
69 enrich_capture_outcome(platform, outcome, options, reader, direct_rtf_reader)
70}
71
72fn try_capture_rich_with_reader(
73 platform: &impl CapturePlatform,
74 store: &impl AppProfileStore,
75 cancel: &impl CancelSignal,
76 adapters: &[&dyn AppAdapter],
77 options: &CaptureRichOptions,
78 reader: &impl RichClipboardReader,
79) -> Result<CaptureRichOutcome, WouldBlock> {
80 try_capture_rich_with_reader_and_direct_reader(
81 platform,
82 store,
83 cancel,
84 adapters,
85 options,
86 reader,
87 &read_direct_rtf_for_current_selection,
88 )
89}
90
91fn try_capture_rich_with_reader_and_direct_reader(
92 platform: &impl CapturePlatform,
93 store: &impl AppProfileStore,
94 cancel: &impl CancelSignal,
95 adapters: &[&dyn AppAdapter],
96 options: &CaptureRichOptions,
97 reader: &impl RichClipboardReader,
98 direct_rtf_reader: &impl Fn() -> Option<String>,
99) -> Result<CaptureRichOutcome, WouldBlock> {
100 let outcome = try_capture(platform, store, cancel, adapters, &options.base)?;
101 Ok(enrich_capture_outcome(
102 platform,
103 outcome,
104 options,
105 reader,
106 direct_rtf_reader,
107 ))
108}
109
110fn enrich_capture_outcome(
111 platform: &impl CapturePlatform,
112 outcome: CaptureOutcome,
113 options: &CaptureRichOptions,
114 reader: &impl RichClipboardReader,
115 _direct_rtf_reader: &impl Fn() -> Option<String>,
116) -> CaptureRichOutcome {
117 match outcome {
118 CaptureOutcome::Failure(failure) => CaptureRichOutcome::Failure(failure),
119 CaptureOutcome::Success(success) => {
120 let plain_text = success.text;
121
122 #[cfg(target_os = "macos")]
123 if options.allow_direct_accessibility_rich && success.method.is_ax() {
124 if let Some(rtf) = _direct_rtf_reader() {
125 if rtf.len() <= options.max_rich_payload_bytes {
126 let markdown = maybe_convert_to_markdown(
127 options,
128 None,
129 Some(rtf.as_str()),
130 &plain_text,
131 );
132 let metadata = ContentMetadata {
133 active_app: detect_active_app(success.trace.as_ref())
134 .or_else(|| platform.active_app()),
135 method: success.method,
136 source: RichSource::AccessibilityAttributed,
137 captured_at_unix_ms: unix_epoch_millis(),
138 plain_text_hash: hash_text(&plain_text),
139 };
140
141 return CaptureRichOutcome::Success(CaptureRichSuccess {
142 content: CapturedContent::Rich(RichPayload {
143 plain_text: plain_text.clone(),
144 html: None,
145 rtf: Some(rtf),
146 markdown,
147 metadata,
148 }),
149 method: success.method,
150 trace: success.trace,
151 });
152 }
153 }
154 }
155
156 if !options.prefer_rich || !options.allow_clipboard_rich {
157 return CaptureRichOutcome::Success(CaptureRichSuccess {
158 content: CapturedContent::Plain(plain_text),
159 method: success.method,
160 trace: success.trace,
161 });
162 }
163
164 let Some(payload) = reader.read() else {
165 return CaptureRichOutcome::Success(CaptureRichSuccess {
166 content: CapturedContent::Plain(plain_text),
167 method: success.method,
168 trace: success.trace,
169 });
170 };
171
172 if exceeds_payload_limit(&payload, options.max_rich_payload_bytes) {
173 return CaptureRichOutcome::Success(CaptureRichSuccess {
174 content: CapturedContent::Plain(plain_text),
175 method: success.method,
176 trace: success.trace,
177 });
178 }
179
180 if options.require_plain_text_match
181 && !clipboard_plain_text_matches(payload.plain_text.as_deref(), &plain_text)
182 {
183 return CaptureRichOutcome::Success(CaptureRichSuccess {
184 content: CapturedContent::Plain(plain_text),
185 method: success.method,
186 trace: success.trace,
187 });
188 }
189
190 let source = if payload.html.is_some() {
191 RichSource::ClipboardHtml
192 } else if payload.rtf.is_some() {
193 RichSource::ClipboardRtf
194 } else {
195 return CaptureRichOutcome::Success(CaptureRichSuccess {
196 content: CapturedContent::Plain(plain_text),
197 method: success.method,
198 trace: success.trace,
199 });
200 };
201
202 let metadata = ContentMetadata {
203 active_app: detect_active_app(success.trace.as_ref())
204 .or_else(|| platform.active_app()),
205 method: success.method,
206 source,
207 captured_at_unix_ms: unix_epoch_millis(),
208 plain_text_hash: hash_text(&plain_text),
209 };
210
211 let html = payload.html;
212 let rtf = payload.rtf;
213 let markdown =
214 maybe_convert_to_markdown(options, html.as_deref(), rtf.as_deref(), &plain_text);
215
216 let rich_payload = RichPayload {
217 plain_text: plain_text.clone(),
218 html,
219 rtf,
220 markdown,
221 metadata,
222 };
223
224 CaptureRichOutcome::Success(CaptureRichSuccess {
225 content: CapturedContent::Rich(rich_payload),
226 method: success.method,
227 trace: success.trace,
228 })
229 }
230 }
231}
232
233#[cfg(target_os = "macos")]
234fn read_direct_rtf_for_current_selection() -> Option<String> {
235 try_selected_rtf_by_ax()
236}
237
238#[cfg(all(feature = "windows-beta", target_os = "windows"))]
239fn read_direct_rtf_for_current_selection() -> Option<String> {
240 try_selected_rtf_by_uia()
241}
242
243#[cfg(all(feature = "linux-alpha", target_os = "linux"))]
244fn read_direct_rtf_for_current_selection() -> Option<String> {
245 try_selected_rtf_by_atspi()
246}
247
248#[cfg(not(any(
249 target_os = "macos",
250 all(feature = "windows-beta", target_os = "windows"),
251 all(feature = "linux-alpha", target_os = "linux")
252)))]
253fn read_direct_rtf_for_current_selection() -> Option<String> {
254 None
255}
256
257fn maybe_convert_to_markdown(
258 options: &CaptureRichOptions,
259 html: Option<&str>,
260 rtf: Option<&str>,
261 plain_text: &str,
262) -> Option<String> {
263 match options.conversion {
264 Some(RichConversion::Markdown) => convert_to_markdown(html, rtf, plain_text),
265 None => None,
266 }
267}
268
269fn exceeds_payload_limit(payload: &RichClipboardPayload, max_bytes: usize) -> bool {
270 payload
271 .html
272 .as_ref()
273 .is_some_and(|value| value.len() > max_bytes)
274 || payload
275 .rtf
276 .as_ref()
277 .is_some_and(|value| value.len() > max_bytes)
278}
279
280fn clipboard_plain_text_matches(clipboard_text: Option<&str>, plain_text: &str) -> bool {
281 let Some(clipboard_text) = clipboard_text else {
282 return false;
283 };
284 normalize_for_match(clipboard_text) == normalize_for_match(plain_text)
285}
286
287fn normalize_for_match(input: &str) -> String {
288 let normalized_line_endings = input.replace("\r\n", "\n").replace('\r', "\n");
289 normalized_line_endings.trim_end_matches('\n').to_string()
290}
291
292fn detect_active_app(trace: Option<&CaptureTrace>) -> Option<ActiveApp> {
293 trace.and_then(|trace| {
294 trace.events.iter().find_map(|event| match event {
295 TraceEvent::ActiveAppDetected(app) => Some(app.clone()),
296 _ => None,
297 })
298 })
299}
300
301fn unix_epoch_millis() -> u128 {
302 match SystemTime::now().duration_since(UNIX_EPOCH) {
303 Ok(duration) => duration.as_millis(),
304 Err(_) => 0,
305 }
306}
307
308fn hash_text(text: &str) -> u64 {
312 const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
313 const FNV_PRIME: u64 = 1_099_511_628_211;
314 let mut hash = FNV_OFFSET;
315 for byte in text.as_bytes() {
316 hash ^= *byte as u64;
317 hash = hash.wrapping_mul(FNV_PRIME);
318 }
319 hash
320}
321
322#[cfg(test)]
323#[path = "rich_engine_tests.rs"]
324mod tests;