talk-rs 0.7.1

Voice dictation for Linux -- record, transcribe, and paste
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
//! Clipboard paste utilities, window-focus helpers, and the
//! composable paste-node tree.
//!
//! Two layers live here:
//!
//! * **Primitives** ([`simulate_paste`], [`simulate_backspace`],
//!   [`ensure_focus`], [`split_into_char_chunks`], [`paste_keysyms`],
//!   [`log_preview`], [`PasteTiming`], [`PASTE_CHUNK_CHARS`]) — the
//!   low-level building blocks shared by every variant of the paste
//!   pipeline.  Unchanged from the pre-tree refactor.
//! * **Node tree** ([`PasteNode`], [`PasteCtx`], [`PasteNodeConfig`],
//!   [`build_root_from_config`], [`default_root`], [`paste_with_root`])
//!   — composable nodes that today reproduce the legacy single-path
//!   pipeline `chunk(150) → clipboard(ctrl-shift-v, 200, 400)` and
//!   tomorrow can be swapped or extended without changing call sites.

pub mod node;
pub mod nodes;

pub use node::{PasteCtx, PasteNode, PasteNodeConfig, WmClassPattern};

use crate::clipboard::{Clipboard, X11Clipboard};
use crate::config::PasteShortcut;
use crate::error::TalkError;

/// Number of leading characters shown in a paste-diagnostic preview.
const PASTE_PREVIEW_CHARS: usize = 60;

/// Render a short, single-line preview of `text` for paste-diagnostic
/// trace logs: the character count plus the first
/// `PASTE_PREVIEW_CHARS` characters with newlines/tabs escaped so a
/// multi-line paste stays on one log line.
///
/// This DOES include clipboard content (potentially sensitive), which
/// is why every call site is gated behind `-vvv` trace logging.
///
/// Unicode-safe: truncation happens on `char` boundaries, never byte
/// offsets, so multibyte text cannot panic.
pub fn log_preview(text: &str) -> String {
    let char_count = text.chars().count();
    let escaped: String = text
        .chars()
        .take(PASTE_PREVIEW_CHARS)
        .map(|c| match c {
            '\n' => '',
            '\r' => '',
            '\t' => '',
            other => other,
        })
        .collect();
    let ellipsis = if char_count > PASTE_PREVIEW_CHARS {
        ""
    } else {
        ""
    };
    format!("{char_count} chars: \"{escaped}{ellipsis}\"")
}

/// Maximum number of attempts to focus the target window.
const FOCUS_MAX_RETRIES: u32 = 5;

/// Initial delay between focus retry attempts (doubles each retry).
const FOCUS_INITIAL_DELAY_MS: u64 = 50;

/// Timing knobs for the paste pipeline.
///
/// Threaded through [`paste_with_root`] for callers that want to
/// override the per-chunk gate deadline / quiescence window without
/// growing the call-site signature unboundedly.
///
/// `restore_settle_ms` is RETAINED on this struct (and in the YAML
/// schema) for backward compatibility but is no longer used at
/// runtime: the pre-restore "settle" heuristic has been replaced by
/// the deterministic per-chunk target-confirmation gate inside the
/// clipboard node, which removes the race between the last chunk's
/// fetch and the clipboard restore by construction.
///
/// `Default` matches the config defaults (200 / 300 / 50).
#[derive(Debug, Clone, Copy)]
pub struct PasteTiming {
    /// **Backward-compat only.**  See struct doc.
    pub restore_settle_ms: u64,
    /// See `paste.chunk_fetch_timeout_ms`.
    pub chunk_fetch_timeout_ms: u64,
    /// See `paste.target_quiescence_ms`.
    pub target_quiescence_ms: u64,
}

impl Default for PasteTiming {
    fn default() -> Self {
        Self {
            restore_settle_ms: 200,
            chunk_fetch_timeout_ms: node::DEFAULT_CHUNK_FETCH_TIMEOUT_MS,
            target_quiescence_ms: node::DEFAULT_TARGET_QUIESCENCE_MS,
        }
    }
}

/// Maximum number of characters per clipboard paste operation.
///
/// When the text to paste exceeds this limit it is split into
/// consecutive chunks, each pasted via a separate Ctrl+Shift+V
/// keystroke.  Splits happen on word boundaries so words are never
/// cut in half.  Keeping chunks under 150 characters avoids
/// triggering paste-summary behaviour in terminal applications
/// that collapse large pastes into an opaque block.
pub const PASTE_CHUNK_CHARS: usize = 150;

/// Attempt to focus the target window and verify the active window
/// matches.  Retries with exponential backoff to give the window
/// manager time to settle after destroying a transient window (e.g.
/// the GTK picker).
///
/// Returns `Ok(())` when the target window is confirmed active, or
/// `Err` if focus could not be established after all retries.
pub async fn ensure_focus(window_id: &str) -> Result<(), TalkError> {
    let mut delay_ms = FOCUS_INITIAL_DELAY_MS;

    for attempt in 1..=FOCUS_MAX_RETRIES {
        focus_window(window_id).await;
        tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;

        if let Some(active) = get_active_window().await {
            if active == window_id {
                log::debug!("target window {} focused (attempt {})", window_id, attempt);
                return Ok(());
            }
            log::debug!(
                "focus attempt {}/{}: expected {}, got {}",
                attempt,
                FOCUS_MAX_RETRIES,
                window_id,
                active,
            );
        } else {
            log::debug!(
                "focus attempt {}/{}: could not determine active window",
                attempt,
                FOCUS_MAX_RETRIES,
            );
        }

        delay_ms *= 2;
    }

    Err(TalkError::Clipboard(format!(
        "could not focus target window {} after {} attempts \
         — aborting to avoid sending keys to the wrong window",
        window_id, FOCUS_MAX_RETRIES,
    )))
}

/// Split `text` into chunks of at most `max_chars` characters each,
/// breaking on word boundaries so words are never cut in half.
///
/// Every chunk after the first is prefixed with a single space so that
/// concatenating all chunks reproduces the original word sequence.
/// If the text is empty (or whitespace-only) a single element containing
/// the original string is returned so that the caller always has at
/// least one chunk to paste.  A single word longer than `max_chars` is
/// emitted as-is (never split mid-word).
pub fn split_into_char_chunks(text: &str, max_chars: usize) -> Vec<String> {
    let words: Vec<&str> = text.split_whitespace().collect();
    if words.is_empty() {
        return vec![text.to_string()];
    }

    let mut chunks = Vec::new();
    let mut current = String::new();

    for word in &words {
        let candidate_len = if current.is_empty() {
            word.len()
        } else {
            current.len() + 1 + word.len() // +1 for the space
        };

        if !current.is_empty() && candidate_len > max_chars {
            chunks.push(current);
            current = format!(" {word}");
        } else if current.is_empty() {
            current = (*word).to_string();
        } else {
            current.push(' ');
            current.push_str(word);
        }
    }

    if !current.is_empty() {
        chunks.push(current);
    }

    chunks
}

/// Materialise the default paste tree:
/// `chunk(150) → clipboard(ctrl-shift-v, 200, 300, 50)` — the exact
/// tree that reproduces post-Phase-2 paste behaviour when no
/// `paste:` section is present in the YAML config.  (Pre-Phase-2 the
/// last knob — `chunk_fetch_timeout_ms` — was 400; lowered to 300 by
/// the per-chunk target-confirmation gate; `200` is the legacy
/// `restore_settle_ms` retained for backward-compat but unused at
/// runtime; `50` is the new `target_quiescence_ms` knob.)
///
/// When `no_chunk_paste` is `true`, the `chunk` wrapper is dropped and
/// the tree collapses to a single `clipboard` leaf — matching the
/// legacy `--no-chunk-paste` flag semantics.
pub fn default_root(no_chunk_paste: bool) -> Box<dyn PasteNode> {
    let mut tree = PasteNodeConfig::Chunk {
        chunk_chars: PASTE_CHUNK_CHARS,
        child: Box::new(PasteNodeConfig::Clipboard {
            shortcut: PasteShortcut::CtrlShiftV,
            restore_settle_ms: PasteTiming::default().restore_settle_ms,
            chunk_fetch_timeout_ms: PasteTiming::default().chunk_fetch_timeout_ms,
            target_quiescence_ms: PasteTiming::default().target_quiescence_ms,
            target_fetch_retries: node::DEFAULT_TARGET_FETCH_RETRIES,
        }),
    };
    if no_chunk_paste {
        tree = tree.strip_chunks();
    }
    tree.build()
}

/// Build the runtime root node from a [`PasteNodeConfig`].  Applies
/// the `no_chunk_paste` flag by stripping any `chunk` wrappers from
/// the configured tree.
pub fn build_root_from_config(cfg: &PasteNodeConfig, no_chunk_paste: bool) -> Box<dyn PasteNode> {
    if no_chunk_paste {
        cfg.clone().strip_chunks().build()
    } else {
        cfg.build()
    }
}

/// Extract the settle-timing knobs from a configured tree.  The
/// settle loop lives in the wrapper ([`paste_with_root`] /
/// [`RealtimeClipboardGuard`]) — see the deviation note on
/// [`PasteCtx`] for the rationale.
pub fn timing_from_root(cfg: &PasteNodeConfig) -> PasteTiming {
    node::timing_from_tree(cfg)
}

/// Paste `text` through the supplied root node, wrapping with
/// save-clipboard / restore-clipboard.  Direct successor of the
/// legacy `paste_text_to_target`.
///
/// Behaviour for the default tree:
/// focus → optional backspace → resolve target client-base → save
/// clipboard → root.paste(text) → restore.  No "settle" stability
/// window is needed at the wrapper level: the
/// `crate::paste::nodes::clipboard::ClipboardNode` gate confirms
/// the target consumed the LAST chunk before returning, so we can
/// restore the original clipboard immediately afterwards.
///
/// `timing` is retained for API compatibility — the per-clipboard
/// gate uses the knobs declared on its own [`PasteNodeConfig::Clipboard`]
/// instance inside the tree, not these struct fields.
#[allow(clippy::too_many_arguments)]
pub async fn paste_with_root(
    root: &dyn PasteNode,
    target_window: Option<&String>,
    text: &str,
    delete_chars_before_paste: usize,
    t_stop: Option<std::time::Instant>,
    sink: &dyn crate::telemetry::TelemetrySink,
    timing: PasteTiming,
    alert: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
) -> Result<(), TalkError> {
    let clipboard = X11Clipboard::new();
    let total_chars = text.len() as u64;

    // Wrapper-level timing knobs are no longer consumed here (the
    // per-chunk gate carries its own).  Kept on the API surface so
    // callers can keep passing their resolved PasteTiming without
    // touching every call site.
    let _ = timing;

    log::trace!(
        "paste: BEGIN delete_before={} target_window={:?} text={}",
        delete_chars_before_paste,
        target_window,
        log_preview(text),
    );

    if let Some(wid) = target_window {
        log::debug!("refocusing target window: {}", wid);
        ensure_focus(wid).await?;
        if let Some(active) = get_active_window().await {
            log::trace!("paste: active window after focus = {}", active);
        }
    }

    if delete_chars_before_paste > 0 {
        log::info!("deleting {} chars before paste", delete_chars_before_paste);
        simulate_backspace(delete_chars_before_paste).await?;
        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
    }

    let saved_clipboard = clipboard.get_text().await.ok();
    log::trace!(
        "paste: saved original clipboard = {}",
        saved_clipboard
            .as_deref()
            .map(log_preview)
            .unwrap_or_else(|| "<none>".to_string()),
    );

    // Legacy "timing: stop +Nms first_paste" log — emitted once,
    // immediately before the first keystroke leaves this process.
    if let Some(t) = t_stop {
        log::info!("timing: stop +{}ms first_paste", t.elapsed().as_millis());
    }

    // Resolve the target X11 client-base ONCE for this paste
    // operation.  See `node::PasteCtx::target_client_base` docs for
    // the contract.  Parse / mask failures fall back to None
    // (blind-paste fallback gate in ClipboardNode) — never hard-fail
    // here, the contract is "best-effort resolution, deterministic
    // path when possible".
    let target_client_base = resolve_target_client_base(target_window).await;

    let target_window_str: Option<&str> = target_window.map(|s| s.as_str());
    let ctx = PasteCtx {
        target_window: target_window_str,
        delete_chars_before_paste,
        t_stop,
        sink,
        clipboard: &clipboard,
        target_client_base,
        expected_target_fetches: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
        alert,
    };

    let paste_result = root.paste(text, &ctx).await;

    // Restore the original clipboard regardless of whether paste
    // succeeded: a half-finished paste should not leave clipboard
    // contents from the failed operation in the user's clipboard.
    if let Some(saved) = saved_clipboard {
        log::trace!(
            "paste: restoring original clipboard = {}",
            log_preview(&saved)
        );
        let _ = clipboard.set_text(&saved).await;
    }

    paste_result?;

    log::trace!("paste: END (total_chars={})", total_chars);
    Ok(())
}

/// Resolve the X11 client-base of the optional target window XID
/// string.
///
/// Returns `None` when `target_window` is absent, when the string
/// fails to parse as a base-10 `u32`, or when the X11 connection
/// used to read `resource_id_mask` cannot be established.  All
/// three failure modes are silently mapped to "fall back to the
/// legacy gate" — none of them should abort the paste, since blind
/// pastes have always worked and this is meant to be a
/// best-effort upgrade.
async fn resolve_target_client_base(target_window: Option<&String>) -> Option<u32> {
    let wid = match target_window.and_then(|s| s.parse::<u32>().ok()) {
        Some(w) => w,
        None => {
            if target_window.is_some() {
                log::debug!(
                    "paste: target_window {:?} could not be parsed as u32 \
                     — falling back to legacy served_count gate",
                    target_window,
                );
            }
            return None;
        }
    };
    let base = tokio::task::spawn_blocking(move || crate::x11::x11_client_base(wid))
        .await
        .ok()
        .flatten();
    match base {
        Some(b) => {
            log::debug!(
                "paste: resolved target X11 client-base {:#x} for window {} \
                 (deterministic gate enabled)",
                b,
                wid,
            );
            Some(b)
        }
        None => {
            log::debug!(
                "paste: failed to resolve X11 client-base for window {} \
                 — falling back to legacy served_count gate",
                wid,
            );
            None
        }
    }
}

/// Per-segment paste guard for the realtime path.
///
/// Today the realtime per-segment loop in `dictate/mod.rs` saved the
/// clipboard before the first segment, pasted each segment via the
/// configured paste tree (chunk wrappers stripped — each segment
/// pastes whole), then restored at the end.  The legacy
/// "settle-before-restore" stability window has been REMOVED here:
/// the per-chunk target-confirmation gate inside the clipboard node
/// already waits for the actual target to consume each segment, so
/// no further stability window is needed at finish time.
///
/// The realtime path normally has no specific target window (the
/// segment is pasted into whatever currently has focus), so the
/// clipboard gate falls back to the legacy `served_count > 0`
/// behaviour with a warning — backward compatible.
pub struct RealtimeClipboardGuard {
    clipboard: X11Clipboard,
    saved: Option<String>,
}

impl RealtimeClipboardGuard {
    /// Save the current clipboard.  Does not pre-focus the target
    /// window — call sites do that separately to preserve today's
    /// ordering.
    ///
    /// `timing` is accepted for backward API compat but no longer
    /// drives any behaviour at the guard level: the per-chunk gate
    /// inside each clipboard-node call owns the timing knobs that
    /// matter.
    pub async fn begin(timing: PasteTiming) -> Self {
        let _ = timing;
        let clipboard = X11Clipboard::new();
        let saved = clipboard.get_text().await.ok();
        log::trace!(
            "paste(realtime): saved original clipboard = {}",
            saved
                .as_deref()
                .map(log_preview)
                .unwrap_or_else(|| "<none>".to_string()),
        );
        Self { clipboard, saved }
    }

    /// Paste one segment through `root`.  Each call is independent —
    /// no chunking is applied (the realtime path always pasted
    /// whole segments).
    pub async fn paste_segment(
        &self,
        root: &dyn PasteNode,
        segment: &str,
        sink: &dyn crate::telemetry::TelemetrySink,
    ) -> Result<(), TalkError> {
        let ctx = PasteCtx {
            target_window: None,
            delete_chars_before_paste: 0,
            t_stop: None,
            sink,
            clipboard: &self.clipboard,
            // Realtime path: no specific target window → fall back
            // to the legacy `served_count > 0` gate inside the
            // clipboard node.  Each segment carries its OWN learn
            // state (the AtomicU32 starts at 0 every call), so even
            // when a target_window IS plumbed in later we'd cleanly
            // learn per-segment.
            target_client_base: None,
            expected_target_fetches: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
            // Realtime path has no target window and no sound player
            // plumbed in — nothing to signal audibly here.
            alert: None,
        };
        root.paste(segment, &ctx).await
    }

    /// Restore the original clipboard.  Idempotent.  No
    /// settle-stability window: the per-chunk gate inside each
    /// `paste_segment` call already confirmed the target consumed
    /// the last segment before returning.
    pub async fn finish(self) {
        if let Some(saved) = self.saved {
            log::debug!("restoring original clipboard");
            log::trace!(
                "paste(realtime): restoring original clipboard = {}",
                log_preview(&saved),
            );
            let _ = self.clipboard.set_text(&saved).await;
        }
    }
}

/// Get the currently focused window ID via `_NET_ACTIVE_WINDOW`.
pub async fn get_active_window() -> Option<String> {
    // The X11 call is blocking but fast; run on a blocking thread
    // so we don't stall the async runtime.
    tokio::task::spawn_blocking(|| crate::x11::x11_get_active_window().map(|wid| wid.to_string()))
        .await
        .ok()?
}

/// Focus a window by ID via `_NET_ACTIVE_WINDOW` ClientMessage.
pub async fn focus_window(window_id: &str) -> bool {
    let wid: u32 = match window_id.parse() {
        Ok(v) => v,
        Err(_) => return false,
    };

    tokio::task::spawn_blocking(move || crate::x11::x11_activate_window(wid))
        .await
        .unwrap_or(false)
}

/// Resolve a [`PasteShortcut`] into the X11 keysyms to send.
///
/// Pure function — enables unit testing without an X11 connection.
pub fn paste_keysyms(shortcut: &PasteShortcut) -> Vec<u32> {
    const CONTROL_L: u32 = 0xffe3;
    const SHIFT_L: u32 = 0xffe1;
    const KEY_V: u32 = 0x0076;

    match shortcut {
        PasteShortcut::CtrlShiftV => vec![CONTROL_L, SHIFT_L, KEY_V],
        PasteShortcut::CtrlV => vec![CONTROL_L, KEY_V],
    }
}

/// Simulate a paste keystroke via the XTest extension.
///
/// The exact key combination depends on `shortcut`:
/// - `PasteShortcut::CtrlShiftV` → Ctrl+Shift+V
/// - `PasteShortcut::CtrlV` → Ctrl+V
pub async fn simulate_paste(shortcut: PasteShortcut) -> Result<(), TalkError> {
    let keysyms = paste_keysyms(&shortcut);

    let ok = tokio::task::spawn_blocking(move || crate::x11::x11_send_key_combo(&keysyms))
        .await
        .unwrap_or(false);

    if !ok {
        return Err(TalkError::Clipboard(
            "XTest key simulation failed".to_string(),
        ));
    }
    Ok(())
}

/// Simulate deleting the previous text by sending repeated BackSpace
/// via the XTest extension.
pub async fn simulate_backspace(count: usize) -> Result<(), TalkError> {
    if count == 0 {
        return Ok(());
    }

    // X11 keysym for BackSpace.
    const BACKSPACE: u32 = 0xff08;

    let ok = tokio::task::spawn_blocking(move || crate::x11::x11_send_key_repeat(BACKSPACE, count))
        .await
        .unwrap_or(false);

    if !ok {
        return Err(TalkError::Clipboard(
            "XTest backspace simulation failed".to_string(),
        ));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_chunk_short_text_fits_in_one() {
        let chunks = split_into_char_chunks("hello world", 150);
        assert_eq!(chunks, vec!["hello world"]);
    }

    #[test]
    fn test_chunk_exactly_at_limit() {
        // 20 chars exactly, limit 20
        let text = "one two three four f";
        assert_eq!(text.len(), 20);
        let chunks = split_into_char_chunks(text, 20);
        assert_eq!(chunks, vec![text]);
    }

    #[test]
    fn test_chunk_splits_on_word_boundary() {
        // "hello world" = 11 chars, limit 8 → split before "world"
        let chunks = split_into_char_chunks("hello world", 8);
        assert_eq!(chunks, vec!["hello", " world"]);
    }

    #[test]
    fn test_chunk_long_word_exceeds_limit() {
        // A single word longer than the limit is emitted as-is
        let chunks = split_into_char_chunks("supercalifragilistic", 5);
        assert_eq!(chunks, vec!["supercalifragilistic"]);
    }

    #[test]
    fn test_chunk_multiple_chunks() {
        // limit 10: "aaa bbb" (7) fits, "aaa bbb ccc" (11) doesn't
        let text = "aaa bbb ccc ddd eee fff";
        let chunks = split_into_char_chunks(text, 10);
        assert_eq!(chunks, vec!["aaa bbb", " ccc ddd", " eee fff"]);
    }

    #[test]
    fn test_chunk_concatenation_reproduces_original() {
        let text = "The quick brown fox jumps over the lazy dog and then some more words follow after that";
        let chunks = split_into_char_chunks(text, 30);
        let reassembled: String = chunks.concat();
        assert_eq!(reassembled, text);
    }

    #[test]
    fn test_chunk_empty_string() {
        let chunks = split_into_char_chunks("", 150);
        assert_eq!(chunks, vec![""]);
    }

    #[test]
    fn test_chunk_whitespace_only() {
        let chunks = split_into_char_chunks("   ", 150);
        assert_eq!(chunks, vec!["   "]);
    }

    #[test]
    fn test_chunk_single_word() {
        let chunks = split_into_char_chunks("hello", 150);
        assert_eq!(chunks, vec!["hello"]);
    }

    #[test]
    fn test_paste_keysyms_ctrl_shift_v() {
        let keysyms = paste_keysyms(&PasteShortcut::CtrlShiftV);
        assert_eq!(keysyms, vec![0xffe3, 0xffe1, 0x0076]);
    }

    #[test]
    fn test_paste_keysyms_ctrl_v() {
        let keysyms = paste_keysyms(&PasteShortcut::CtrlV);
        assert_eq!(keysyms, vec![0xffe3, 0x0076]);
    }

    #[test]
    fn test_log_preview_short_text_not_truncated() {
        assert_eq!(log_preview("hello"), "5 chars: \"hello\"");
    }

    #[test]
    fn test_log_preview_empty() {
        assert_eq!(log_preview(""), "0 chars: \"\"");
    }

    #[test]
    fn test_log_preview_escapes_newlines_and_tabs() {
        // Newline, carriage return, and tab are replaced with visible
        // control pictures so a multi-line paste stays on one log line.
        assert_eq!(log_preview("a\nb\tc\rd"), "7 chars: \"a␊b␉c␍d\"");
    }

    #[test]
    fn test_log_preview_truncates_with_ellipsis() {
        let text = "x".repeat(PASTE_PREVIEW_CHARS + 10);
        let preview = log_preview(&text);
        let expected_body = "x".repeat(PASTE_PREVIEW_CHARS);
        assert_eq!(
            preview,
            format!("{} chars: \"{}\"", PASTE_PREVIEW_CHARS + 10, expected_body),
        );
    }

    #[test]
    fn test_log_preview_boundary_exactly_preview_chars_no_ellipsis() {
        let text = "y".repeat(PASTE_PREVIEW_CHARS);
        let preview = log_preview(&text);
        assert!(!preview.contains(''));
        assert_eq!(
            preview,
            format!("{} chars: \"{}\"", PASTE_PREVIEW_CHARS, text),
        );
    }

    #[test]
    fn test_log_preview_multibyte_char_boundary_safe() {
        // Each emoji is one `char` but 4 bytes; truncation must happen
        // on char boundaries so this never panics and counts chars,
        // not bytes.
        let text = "😀".repeat(PASTE_PREVIEW_CHARS + 5);
        let preview = log_preview(&text);
        assert!(preview.starts_with(&format!("{} chars: ", PASTE_PREVIEW_CHARS + 5)));
        assert!(preview.ends_with("\""));
        // Exactly PASTE_PREVIEW_CHARS emojis are shown before the ellipsis.
        let shown = "😀".repeat(PASTE_PREVIEW_CHARS);
        assert!(preview.contains(&shown));
    }
}

#[cfg(test)]
mod tree_tests {
    //! Tests for the paste-node tree config + builders.  Covers:
    //! - new tree YAML deserialises
    //! - old flat YAML deserialises into equivalent tree
    //! - missing `paste:` → default tree
    //! - first-match routing in `match-wm-class`
    //! - glob matching for WM_CLASS
    //! - chunk node reproduces `split_into_char_chunks` behaviour

    use super::node::{PasteCtx, PasteNode, PasteNodeConfig};
    use super::nodes::chunk::ChunkNode;
    use super::nodes::glob_match;
    use crate::clipboard::X11Clipboard;
    use crate::config::{Config, PasteConfig, PasteShortcut};
    use crate::telemetry::{NoOpSink, TelemetrySink, TranscriptionEvent};
    use async_trait::async_trait;
    use std::sync::Arc;
    use std::sync::Mutex;

    /// Helper: parse a tiny full Config from inline YAML and return
    /// its `paste` field.
    fn parse_paste(yaml: &str) -> Option<PasteConfig> {
        let cfg: Config = serde_yaml::from_str(yaml).expect("yaml fixture must parse as Config");
        cfg.paste
    }

    #[test]
    fn flat_yaml_deserialises_as_flat_variant() {
        let yaml = r#"
output_dir: /tmp/x
providers: {}
paste:
  chunk_chars: 80
  shortcut: ctrl_v
  restore_settle_ms: 250
  chunk_fetch_timeout_ms: 600
"#;
        let p = parse_paste(yaml).expect("paste section");
        match p {
            PasteConfig::Flat(ref f) => {
                assert_eq!(f.chunk_chars, 80);
                assert_eq!(f.shortcut, PasteShortcut::CtrlV);
                assert_eq!(f.restore_settle_ms, 250);
                assert_eq!(f.chunk_fetch_timeout_ms, 600);
            }
            PasteConfig::Tree(_) => panic!("expected flat variant for legacy YAML"),
        }

        // Building the root tree from flat must collapse to
        // chunk(80) → clipboard(ctrl_v, 250, 600, <default-quiescence>).
        let tree = p.to_tree();
        match tree {
            PasteNodeConfig::Chunk { chunk_chars, child } => {
                assert_eq!(chunk_chars, 80);
                match *child {
                    PasteNodeConfig::Clipboard {
                        shortcut,
                        restore_settle_ms,
                        chunk_fetch_timeout_ms,
                        target_quiescence_ms,
                        target_fetch_retries,
                    } => {
                        assert_eq!(shortcut, PasteShortcut::CtrlV);
                        assert_eq!(restore_settle_ms, 250);
                        assert_eq!(chunk_fetch_timeout_ms, 600);
                        // Flat YAML has no target_quiescence_ms knob
                        // (added by Phase 2 at the node-tree level only);
                        // flat → tree adapter falls back to the default.
                        assert_eq!(target_quiescence_ms, 50);
                        // Flat YAML also omits target_fetch_retries here;
                        // flat → tree adapter falls back to the default.
                        assert_eq!(target_fetch_retries, 2);
                    }
                    other => panic!("expected Clipboard child, got {:?}", other),
                }
            }
            other => panic!("expected Chunk root, got {:?}", other),
        }
    }

    #[test]
    fn flat_yaml_with_chunk_chars_zero_skips_chunk_wrapper() {
        let yaml = r#"
output_dir: /tmp/x
providers: {}
paste:
  chunk_chars: 0
"#;
        let p = parse_paste(yaml).expect("paste section");
        match p.to_tree() {
            PasteNodeConfig::Clipboard { .. } => {}
            other => panic!("expected Clipboard root for chunk_chars=0, got {:?}", other),
        }
    }

    #[test]
    fn tree_yaml_deserialises_as_tree_variant() {
        let yaml = r#"
output_dir: /tmp/x
providers: {}
paste:
  node: chunk
  chunk_chars: 120
  child:
    node: clipboard
    shortcut: ctrl_shift_v
    restore_settle_ms: 150
    chunk_fetch_timeout_ms: 350
    target_quiescence_ms: 60
"#;
        let p = parse_paste(yaml).expect("paste section");
        match p {
            PasteConfig::Tree(t) => match t {
                PasteNodeConfig::Chunk { chunk_chars, child } => {
                    assert_eq!(chunk_chars, 120);
                    match *child {
                        PasteNodeConfig::Clipboard {
                            shortcut,
                            restore_settle_ms,
                            chunk_fetch_timeout_ms,
                            target_quiescence_ms,
                            target_fetch_retries,
                        } => {
                            assert_eq!(shortcut, PasteShortcut::CtrlShiftV);
                            assert_eq!(restore_settle_ms, 150);
                            assert_eq!(chunk_fetch_timeout_ms, 350);
                            assert_eq!(target_quiescence_ms, 60);
                            // Omitted in this fixture → default.
                            assert_eq!(target_fetch_retries, 2);
                        }
                        other => panic!("expected Clipboard child, got {:?}", other),
                    }
                }
                other => panic!("expected Chunk root, got {:?}", other),
            },
            PasteConfig::Flat(_) => panic!("expected tree variant for `node:`-tagged YAML"),
        }
    }

    #[test]
    fn tree_yaml_with_match_wm_class_routing() {
        let yaml = r#"
output_dir: /tmp/x
providers: {}
paste:
  node: match-wm-class
  patterns:
    - match: "firefox.*"
      child:
        node: clipboard
        shortcut: ctrl_v
        restore_settle_ms: 200
        chunk_fetch_timeout_ms: 400
    - match: "*.Emacs"
      child:
        node: xtest-type
  default:
    node: clipboard
    shortcut: ctrl_shift_v
    restore_settle_ms: 200
    chunk_fetch_timeout_ms: 400
"#;
        let p = parse_paste(yaml).expect("paste section");
        match p.to_tree() {
            PasteNodeConfig::MatchWmClass { patterns, default } => {
                assert_eq!(patterns.len(), 2);
                assert_eq!(patterns[0].pattern, "firefox.*");
                assert_eq!(patterns[1].pattern, "*.Emacs");
                assert!(matches!(*default, PasteNodeConfig::Clipboard { .. }));
            }
            other => panic!("expected MatchWmClass root, got {:?}", other),
        }
    }

    #[test]
    fn missing_paste_section_yields_none_and_default_root_replicates_legacy() {
        let yaml = r#"
output_dir: /tmp/x
providers: {}
"#;
        let cfg: Config = serde_yaml::from_str(yaml).expect("parses");
        assert!(cfg.paste.is_none());

        // Default tree: chunk(150) → clipboard(ctrl_shift_v, 200,
        // 500, 50).  Note 500 vs the pre-retry 300 — see the
        // `DEFAULT_CHUNK_FETCH_TIMEOUT_MS` constant doc.
        let default = PasteNodeConfig::Chunk {
            chunk_chars: super::PASTE_CHUNK_CHARS,
            child: Box::new(PasteNodeConfig::Clipboard {
                shortcut: PasteShortcut::CtrlShiftV,
                restore_settle_ms: super::PasteTiming::default().restore_settle_ms,
                chunk_fetch_timeout_ms: super::PasteTiming::default().chunk_fetch_timeout_ms,
                target_quiescence_ms: super::PasteTiming::default().target_quiescence_ms,
                target_fetch_retries: super::node::DEFAULT_TARGET_FETCH_RETRIES,
            }),
        };
        let timing = super::node::timing_from_tree(&default);
        assert_eq!(timing.restore_settle_ms, 200);
        assert_eq!(timing.chunk_fetch_timeout_ms, 500);
        assert_eq!(timing.target_quiescence_ms, 50);
    }

    #[test]
    fn glob_first_match_wins_in_wm_class_patterns() {
        // Two patterns that BOTH match "firefox.Firefox" — the first
        // declared wins.
        assert!(glob_match("firefox.*", "firefox.Firefox"));
        assert!(glob_match("*.Firefox", "firefox.Firefox"));
        assert!(glob_match("*", "firefox.Firefox"));
    }

    #[test]
    fn glob_matches_wm_class_strings() {
        assert!(glob_match("*.Emacs", "emacs.Emacs"));
        assert!(!glob_match("*.Emacs", "vim.Vim"));
        assert!(glob_match("Navigator.*", "Navigator.Firefox"));
        assert!(glob_match("*", "anything.AtAll"));
    }

    #[test]
    fn no_chunk_paste_strips_chunk_wrappers_anywhere_in_tree() {
        let tree = PasteNodeConfig::Chunk {
            chunk_chars: 100,
            child: Box::new(PasteNodeConfig::MatchWmClass {
                patterns: vec![super::node::WmClassPattern {
                    pattern: "*".to_string(),
                    child: Box::new(PasteNodeConfig::Chunk {
                        chunk_chars: 50,
                        child: Box::new(PasteNodeConfig::Clipboard {
                            shortcut: PasteShortcut::CtrlV,
                            restore_settle_ms: 200,
                            chunk_fetch_timeout_ms: 400,
                            target_quiescence_ms: 50,
                            target_fetch_retries: 2,
                        }),
                    }),
                }],
                default: Box::new(PasteNodeConfig::Clipboard {
                    shortcut: PasteShortcut::CtrlShiftV,
                    restore_settle_ms: 200,
                    chunk_fetch_timeout_ms: 400,
                    target_quiescence_ms: 50,
                    target_fetch_retries: 2,
                }),
            }),
        };
        let stripped = tree.strip_chunks();
        // Top-level Chunk is gone; inner Chunk under MatchWmClass is also gone.
        match stripped {
            PasteNodeConfig::MatchWmClass { patterns, default } => {
                assert!(matches!(
                    *patterns[0].child,
                    PasteNodeConfig::Clipboard { .. }
                ));
                assert!(matches!(*default, PasteNodeConfig::Clipboard { .. }));
            }
            other => panic!("expected MatchWmClass after strip, got {:?}", other),
        }
    }

    /// A leaf node that records every payload it sees — lets the
    /// chunk-node test verify what was forwarded.
    struct RecordingSink(Arc<Mutex<Vec<String>>>);

    #[async_trait]
    impl PasteNode for RecordingSink {
        async fn paste(
            &self,
            text: &str,
            _ctx: &PasteCtx<'_>,
        ) -> Result<(), crate::error::TalkError> {
            self.0
                .lock()
                .expect("lock RecordingSink")
                .push(text.to_string());
            Ok(())
        }
    }

    /// A telemetry sink that records every `PasteProgress` event.
    struct ProgressRecorder(Arc<Mutex<Vec<(u64, u64)>>>);

    impl TelemetrySink for ProgressRecorder {
        fn emit(&self, ev: TranscriptionEvent) {
            if let TranscriptionEvent::PasteProgress {
                chars_pasted,
                total_chars,
                ..
            } = ev
            {
                self.0
                    .lock()
                    .expect("lock ProgressRecorder")
                    .push((chars_pasted, total_chars));
            }
        }
    }

    #[tokio::test]
    async fn chunk_node_forwards_same_chunks_as_split_into_char_chunks() {
        let text = "aaa bbb ccc ddd eee fff";
        let chunk_chars = 10;
        let expected = super::split_into_char_chunks(text, chunk_chars);

        let received = Arc::new(Mutex::new(Vec::<String>::new()));
        let progress = Arc::new(Mutex::new(Vec::<(u64, u64)>::new()));
        let progress_sink = ProgressRecorder(progress.clone());

        let chunk = ChunkNode {
            chunk_chars,
            child: Box::new(RecordingSink(received.clone())),
        };

        let clipboard = X11Clipboard::new();
        let ctx = PasteCtx {
            target_window: None,
            delete_chars_before_paste: 0,
            t_stop: None,
            sink: &progress_sink,
            clipboard: &clipboard,
            target_client_base: None,
            expected_target_fetches: Arc::new(std::sync::atomic::AtomicU32::new(0)),
            alert: None,
        };

        chunk.paste(text, &ctx).await.expect("paste");

        let got = received.lock().expect("lock received").clone();
        assert_eq!(got, expected);

        // Cumulative chars_pasted progresses to total_chars=text.len().
        let total = text.len() as u64;
        let progress = progress.lock().expect("lock progress").clone();
        assert_eq!(progress.len(), expected.len());
        let mut cum: u64 = 0;
        for ((cp, tc), chunk) in progress.iter().zip(expected.iter()) {
            cum += chunk.len() as u64;
            assert_eq!(*tc, total);
            assert_eq!(*cp, cum);
        }
    }

    #[tokio::test]
    async fn chunk_node_with_zero_chunk_chars_pastes_whole_text_once() {
        let text = "hello world";
        let received = Arc::new(Mutex::new(Vec::<String>::new()));

        let chunk = ChunkNode {
            chunk_chars: 0,
            child: Box::new(RecordingSink(received.clone())),
        };

        let clipboard = X11Clipboard::new();
        let ctx = PasteCtx {
            target_window: None,
            delete_chars_before_paste: 0,
            t_stop: None,
            sink: &NoOpSink,
            clipboard: &clipboard,
            target_client_base: None,
            expected_target_fetches: Arc::new(std::sync::atomic::AtomicU32::new(0)),
            alert: None,
        };

        chunk.paste(text, &ctx).await.expect("paste");

        let got = received.lock().expect("lock").clone();
        assert_eq!(got, vec!["hello world".to_string()]);
    }
}