Skip to main content

tui_lipan/widgets/text_area/
sentinel.rs

1use std::any::Any;
2use std::fmt;
3use std::hash::{Hash, Hasher};
4use std::sync::Arc;
5use std::sync::atomic::{AtomicU64, Ordering};
6
7use crate::clipboard::ImageContent;
8use crate::core::event::MouseEvent;
9
10static NEXT_SENTINEL_ID: AtomicU64 = AtomicU64::new(1);
11
12/// Opaque stable identifier for a sentinel, unique within a TextArea lifetime.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14pub struct SentinelId(u64);
15
16impl SentinelId {
17    /// Reserved when no stable id was assigned (e.g. legacy sentinel).
18    pub const UNKNOWN: Self = Self(0);
19
20    pub(crate) fn next() -> Self {
21        Self(NEXT_SENTINEL_ID.fetch_add(1, Ordering::Relaxed))
22    }
23}
24
25/// Base codepoint for custom (non-image) inline sentinels.
26///
27/// Each custom sentinel at index `i` in the `sentinels` list is represented by
28/// `char::from_u32(SENTINEL_BASE as u32 + i as u32).unwrap()` in the value string.
29/// Uses `U+F000` to avoid collision with image sentinels at `U+E000`.
30pub const SENTINEL_BASE: char = '\u{F000}';
31/// A user-defined inline sentinel token embedded in [`TextArea`](crate::widgets::TextArea) text.
32///
33/// Each entry maps to a Private Use Area character in the value string and is
34/// rendered as a styled label. Sentinels behave atomically: a single backspace
35/// or delete removes the whole token.
36#[derive(Clone)]
37pub struct TextAreaSentinel {
38    /// The label rendered in place of the sentinel character.
39    pub label: std::sync::Arc<str>,
40    /// Style applied when the textarea is unfocused.
41    pub style: crate::style::Style,
42    /// Style applied when the textarea is focused. Falls back to `style` when `None`.
43    pub focus_style: Option<crate::style::Style>,
44    /// Style patched over the rendered label while the pointer hovers this sentinel.
45    pub hover_style: Option<crate::style::Style>,
46    payload: Option<Arc<dyn Any + Send + Sync>>,
47    id: Option<SentinelId>,
48}
49
50impl PartialEq for TextAreaSentinel {
51    fn eq(&self, other: &Self) -> bool {
52        self.label == other.label
53            && self.style == other.style
54            && self.focus_style == other.focus_style
55            && self.hover_style == other.hover_style
56            && self.id == other.id
57    }
58}
59
60impl Eq for TextAreaSentinel {}
61
62impl Hash for TextAreaSentinel {
63    fn hash<H: Hasher>(&self, state: &mut H) {
64        self.label.hash(state);
65        self.style.hash(state);
66        self.focus_style.hash(state);
67        self.hover_style.hash(state);
68        self.id.hash(state);
69    }
70}
71
72impl fmt::Debug for TextAreaSentinel {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        f.debug_struct("TextAreaSentinel")
75            .field("label", &self.label)
76            .field("style", &self.style)
77            .field("focus_style", &self.focus_style)
78            .field("hover_style", &self.hover_style)
79            .field("id", &self.id)
80            .field(
81                "payload_type_id",
82                &self.payload.as_ref().map(|p| Any::type_id(p.as_ref())),
83            )
84            .finish()
85    }
86}
87
88impl TextAreaSentinel {
89    /// Create a new sentinel with a label and default (empty) styles.
90    pub fn new(label: impl Into<std::sync::Arc<str>>) -> Self {
91        Self {
92            label: label.into(),
93            style: crate::style::Style::default(),
94            focus_style: None,
95            hover_style: None,
96            payload: None,
97            id: None,
98        }
99    }
100
101    /// Attach type-erased user data (see [`Self::get_payload`]).
102    pub fn payload<T: Send + Sync + 'static>(mut self, data: T) -> Self {
103        self.payload = Some(Arc::new(data));
104        self
105    }
106
107    /// Downcast the payload to `T`.
108    pub fn get_payload<T: Send + Sync + 'static>(&self) -> Option<&T> {
109        self.payload.as_ref()?.downcast_ref::<T>()
110    }
111
112    /// Borrow the payload as [`Any`] for custom downcasting.
113    pub fn raw_payload(&self) -> Option<&Arc<dyn Any + Send + Sync>> {
114        self.payload.as_ref()
115    }
116
117    /// Set a stable id (otherwise [`insert_sentinel`] assigns one).
118    pub fn id(mut self, id: SentinelId) -> Self {
119        self.id = Some(id);
120        self
121    }
122
123    /// Stable id when set.
124    pub fn sentinel_id(&self) -> Option<SentinelId> {
125        self.id
126    }
127
128    /// Set the style.
129    pub fn style(mut self, style: crate::style::Style) -> Self {
130        self.style = style;
131        self
132    }
133
134    /// Set the focused style.
135    pub fn focus_style(mut self, style: crate::style::Style) -> Self {
136        self.focus_style = Some(style);
137        self
138    }
139
140    /// Set the hover style patched over the rendered label while the pointer is over it.
141    pub fn hover_style(mut self, style: crate::style::Style) -> Self {
142        self.hover_style = Some(style);
143        self
144    }
145}
146
147/// Lifecycle event emitted when sentinels change.
148#[derive(Clone, Debug)]
149pub enum SentinelEvent {
150    /// A sentinel was deleted by user edit. Payload preserved for cleanup.
151    Deleted {
152        /// Stable id ([`SentinelId::UNKNOWN`] if none was assigned).
153        id: SentinelId,
154        /// Full sentinel including payload.
155        sentinel: TextAreaSentinel,
156    },
157}
158
159/// A clicked inline sentinel inside a [`TextArea`](crate::widgets::TextArea).
160#[derive(Clone, Debug, PartialEq, Eq)]
161pub enum TextAreaSentinelClickKind {
162    /// An inline image sentinel (`IMAGE_SENTINEL_BASE + index`) was clicked.
163    Image {
164        /// Image index in the `TextArea::images` list.
165        index: usize,
166        /// Image payload associated with the clicked sentinel.
167        image: ImageContent,
168    },
169    /// A custom inline sentinel (`SENTINEL_BASE + index`) was clicked.
170    Custom {
171        /// Sentinel index in the `TextArea::sentinels` list.
172        index: usize,
173        /// Stable id (`SentinelId::UNKNOWN` when unset).
174        id: SentinelId,
175        /// Full sentinel metadata, including payload.
176        sentinel: TextAreaSentinel,
177    },
178}
179
180/// Event emitted when the user clicks an inline sentinel placeholder.
181#[derive(Clone, Debug, PartialEq, Eq)]
182pub struct TextAreaSentinelClickEvent {
183    /// The clicked sentinel payload.
184    pub kind: TextAreaSentinelClickKind,
185    /// Byte range of the sentinel character in the text value.
186    pub byte_range: (usize, usize),
187    /// Mouse event that activated the sentinel.
188    pub mouse: MouseEvent,
189}
190
191/// Insert a custom sentinel into `value` at `cursor`, appending it to `sentinels`.
192///
193/// Returns `(new_value, new_cursor)`. The caller is responsible for updating both
194/// the value and the sentinels list on the TextArea widget.
195pub fn insert_sentinel(
196    value: &str,
197    cursor: usize,
198    sentinels: &mut Vec<TextAreaSentinel>,
199    mut sentinel: TextAreaSentinel,
200) -> (String, usize) {
201    if sentinel.id.is_none() {
202        sentinel.id = Some(SentinelId::next());
203    }
204    let idx = sentinels.len();
205    sentinels.push(sentinel);
206    let ch = char::from_u32(SENTINEL_BASE as u32 + idx as u32).unwrap_or(SENTINEL_BASE);
207    let mut new_value = String::with_capacity(value.len() + ch.len_utf8());
208    let cursor = crate::utils::text::clamp_cursor(value, cursor);
209    new_value.push_str(&value[..cursor]);
210    new_value.push(ch);
211    new_value.push_str(&value[cursor..]);
212    let new_cursor = cursor + ch.len_utf8();
213    (new_value, new_cursor)
214}
215
216/// First Unicode Private Use Area character used as an inline image sentinel.
217/// Each image at index `i` in the `images` list is represented by
218/// `char::from_u32(IMAGE_SENTINEL_BASE as u32 + i as u32).unwrap()` in the value string.
219pub const IMAGE_SENTINEL_BASE: char = '\u{E000}';
220
221/// Whether images are embedded inline in the text value (sentinel chars) or
222/// displayed as attachment chips above the text input area.
223#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
224pub enum TextAreaImageMode {
225    /// Images are embedded inline as sentinel characters (U+E000…).
226    /// The renderer draws the `image_placeholder` label in their place.
227    #[default]
228    Inline,
229    /// Images are shown as chip labels above the text content area.
230    /// Pasting an image appends to the `images` list without touching the value.
231    Attachment,
232}