Skip to main content

kimun_notes/components/
mod.rs

1pub mod activity_rail;
2pub mod ask_sources;
3pub mod ask_thread;
4pub mod attachment_view;
5pub mod autocomplete;
6pub mod autosave_timer;
7pub mod button_row;
8pub mod clickable;
9pub mod command_palette;
10pub mod config_panel;
11pub mod dialogs;
12pub mod dir_browser;
13pub mod drawer;
14pub mod drawer_views;
15pub mod event_state;
16pub mod events;
17pub mod file_list;
18pub mod footer_bar;
19pub mod hint_row;
20pub mod hints;
21pub mod indexing;
22pub mod key_picker;
23pub mod markdown_lines;
24pub mod note_browser;
25pub mod overlay;
26pub mod panel;
27pub mod preferences;
28pub mod preview_highlight;
29pub mod preview_pane;
30pub mod query_highlight;
31pub mod query_list_panel;
32pub mod query_panel;
33pub mod query_vars;
34pub mod rich_row;
35pub mod saved_search_breadcrumb;
36pub mod saved_searches_modal;
37pub mod search_list;
38pub mod semantic_search;
39pub mod sidebar;
40pub mod single_line_input;
41pub mod sortable;
42pub mod text_editor;
43pub mod which_key;
44
45use ratatui::Frame;
46use ratatui::layout::Rect;
47
48/// How close together two clicks have to be to read as one gesture (a
49/// double-click). Shorter than `TypingRun::IDLE`, which measures something
50/// else: that is a *pause* (how long before a human stops feeling they are
51/// still typing), this is a *deliberate burst*. macOS and Windows default to
52/// 500ms, GNOME to 400ms.
53pub const DOUBLE_CLICK: std::time::Duration = std::time::Duration::from_millis(400);
54
55use crate::components::event_state::EventState;
56use crate::components::events::{AppEvent, AppTx, InputEvent};
57use crate::settings::themes::Theme;
58
59/// The process-wide `arboard::Clipboard`.
60///
61/// **It must outlive the write.** On X11 `set_text` transfers *ownership* of the
62/// CLIPBOARD selection to this process, and the contents are then served on
63/// demand by arboard's background thread. Dropping the handle right after
64/// writing can therefore lose what was just copied — arboard warns about exactly
65/// this ("Clipboard was dropped very quickly after writing (0ms); clipboard
66/// managers may not have seen the contents"). `set_text` still returns `Ok`, so
67/// a per-call handle reports success while the paste silently fails.
68///
69/// One handle for the whole TUI, rather than the two policies that preceded it:
70/// a per-call handle (loses ownership) and a per-component cached handle (kept
71/// ownership, but a single failed `Clipboard::new()` at construction disabled
72/// that component's clipboard for the entire session, silently). The `Option`
73/// below is what avoids the latter — a failure is dropped, not cached, so the
74/// next attempt opens a fresh connection.
75static CLIPBOARD: std::sync::OnceLock<std::sync::Mutex<Option<arboard::Clipboard>>> =
76    std::sync::OnceLock::new();
77
78/// Run `f` against the shared clipboard, opening it if needed. A failing
79/// operation drops the handle so the next call reconnects.
80pub(crate) fn with_clipboard<T>(
81    f: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboard::Error>,
82) -> Result<T, arboard::Error> {
83    let cell = CLIPBOARD.get_or_init(|| std::sync::Mutex::new(None));
84    // A poisoned lock means a previous holder panicked mid-operation; the
85    // handle is suspect, so take the guard and rebuild from scratch.
86    let mut guard = cell.lock().unwrap_or_else(|e| {
87        let mut g = e.into_inner();
88        *g = None;
89        g
90    });
91    if guard.is_none() {
92        *guard = Some(arboard::Clipboard::new()?);
93    }
94    let result = f(guard.as_mut().expect("just opened"));
95    // Drop the handle only on a failure that suggests the *connection* is bad.
96    // `ContentNotAvailable` is a statement about the clipboard's contents, not
97    // about the handle — and it is the ordinary answer when `take_clipboard_image`
98    // probes a text clipboard ahead of every Ctrl+V. Dropping on it would
99    // release the X11 CLIPBOARD-selection ownership we hold from our own last
100    // copy, losing the copied text: the exact failure this shared handle exists
101    // to prevent.
102    if matches!(&result, Err(e) if !matches!(e, arboard::Error::ContentNotAvailable)) {
103        *guard = None;
104    }
105    result
106}
107
108/// Put `text` on the OS clipboard and flash the outcome: `done_msg` on
109/// success, `"clipboard: {e}"` on failure. **The** seam for every OS-clipboard
110/// write in the TUI — list-row yanks, ask answers and sources, and the editor's
111/// own Ctrl+C/Ctrl+X.
112pub fn yank(text: String, done_msg: impl Into<String>, tx: &AppTx) {
113    let msg = match with_clipboard(|c| c.set_text(text)) {
114        Ok(()) => done_msg.into(),
115        Err(e) => format!("clipboard: {e}"),
116    };
117    tx.send(AppEvent::FlashMessage(msg)).ok();
118}
119
120/// Perform a [`crate::components::search_list::KeyReaction::Yank`]: copy the
121/// row's target and name what was copied ("path copied", "tag copied"), or say
122/// there was nothing to copy.
123///
124/// The `None` branch is the point. Silence there is indistinguishable from a
125/// clipboard failure or from an unbound key, which is precisely how the missing
126/// note-browser yank stayed invisible.
127pub fn yank_row(target: Option<search_list::YankTarget>, tx: &AppTx) {
128    match target {
129        Some(t) => yank(t.text, format!("{} copied", t.noun), tx),
130        None => {
131            tx.send(AppEvent::FlashMessage("nothing to copy".into()))
132                .ok();
133        }
134    }
135}
136
137/// Centre a popup occupying `percent_x`% × `percent_y`% of `area`.
138/// A centered rect of fixed cell size, clamped to `r` — the counterpart to
139/// the percentage-based [`centered_rect`] for dialogs with intrinsic sizes.
140pub fn fixed_centered_rect(width: u16, height: u16, r: Rect) -> Rect {
141    let width = width.min(r.width);
142    let height = height.min(r.height);
143    Rect {
144        x: r.x + (r.width - width) / 2,
145        y: r.y + (r.height - height) / 2,
146        width,
147        height,
148    }
149}
150
151pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
152    let popup_height = (area.height as u32 * percent_y as u32 / 100) as u16;
153    let popup_width = (area.width as u32 * percent_x as u32 / 100) as u16;
154    Rect {
155        x: area.x + (area.width.saturating_sub(popup_width)) / 2,
156        y: area.y + (area.height.saturating_sub(popup_height)) / 2,
157        width: popup_width,
158        height: popup_height,
159    }
160}
161
162pub trait Component {
163    /// Handle an event. Send `AppEvent`s through `tx` for app-level effects.
164    /// Returns whether this component consumed the event.
165    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
166        let _ = (event, tx);
167        EventState::NotConsumed
168    }
169
170    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool);
171
172    /// Context-sensitive shortcut hints shown in the hints bar when this
173    /// component is focused.  Each entry is `(key_display, label)`.
174    fn hint_shortcuts(&self) -> Vec<(String, String)> {
175        vec![]
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn yank_flashes_outcome_on_tx() {
185        // Headless test runs may have no OS clipboard, so this asserts a
186        // FlashMessage arrives either way — success or the "clipboard: {e}"
187        // error form — not which one.
188        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
189        yank("hello".to_string(), "hello copied", &tx);
190        let ev = rx.try_recv().expect("yank sends exactly one event");
191        match ev {
192            AppEvent::FlashMessage(msg) => {
193                assert!(
194                    msg == "hello copied" || msg.starts_with("clipboard: "),
195                    "unexpected flash message: {msg}"
196                );
197            }
198            other => panic!("expected FlashMessage, got {other:?}"),
199        }
200    }
201
202    #[test]
203    fn centered_rect_is_centered() {
204        let area = Rect {
205            x: 0,
206            y: 0,
207            width: 100,
208            height: 40,
209        };
210        let r = centered_rect(80, 75, area);
211        assert_eq!(r.width, 80);
212        assert_eq!(r.height, 30);
213        assert_eq!(r.x, 10); // (100 - 80) / 2
214        assert_eq!(r.y, 5); // (40 - 30) / 2
215    }
216
217    #[test]
218    fn centered_rect_does_not_underflow() {
219        // Very small area — must not panic.
220        let area = Rect {
221            x: 0,
222            y: 0,
223            width: 5,
224            height: 5,
225        };
226        let _ = centered_rect(80, 75, area);
227    }
228}