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