rgpui 1.2.1

GUI UI framework
Documentation
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
//! 标签组件,支持文本高亮、选择和自定义渲染的文本元素。

use std::ops::Range;

use crate::prelude::FluentBuilder;
use crate::{
    ActiveTheme, App, HighlightStyle, IntoElement, ParentElement, RenderOnce, SharedString,
    StyleRefinement, Styled, StyledExt, StyledText, Window, div, rems,
};

const MASKED: &'static str = "";

/// 表示标签文本高亮的匹配类型。
#[derive(Clone)]
pub enum HighlightsMatch {
    /// 前缀匹配(仅匹配文本开头的搜索词)
    Prefix(SharedString),
    /// 全文匹配(匹配所有出现的位置)
    Full(SharedString),
}

impl HighlightsMatch {
    /// 返回匹配字符串。
    pub fn as_str(&self) -> &str {
        match self {
            Self::Prefix(s) => s.as_str(),
            Self::Full(s) => s.as_str(),
        }
    }

    /// 是否为前缀匹配。
    #[inline]
    pub fn is_prefix(&self) -> bool {
        matches!(self, Self::Prefix(_))
    }
}

impl From<&str> for HighlightsMatch {
    fn from(value: &str) -> Self {
        Self::Full(value.to_string().into())
    }
}

impl From<String> for HighlightsMatch {
    fn from(value: String) -> Self {
        Self::Full(value.into())
    }
}

impl From<SharedString> for HighlightsMatch {
    fn from(value: SharedString) -> Self {
        Self::Full(value)
    }
}

/// 文本标签元素,支持可选的次要文本、掩码与高亮功能。
#[derive(IntoElement)]
pub struct Label {
    /// 样式精炼
    style: StyleRefinement,
    /// 主标签文本
    label: SharedString,
    /// 次要文本
    secondary: Option<SharedString>,
    /// 是否掩码显示
    masked: bool,
    /// 高亮匹配文本
    highlights_text: Option<HighlightsMatch>,
}

impl Label {
    /// 创建带有主文本的新标签。
    pub fn new(label: impl Into<SharedString>) -> Self {
        let label: SharedString = label.into();
        Self {
            style: Default::default(),
            label,
            secondary: None,
            masked: false,
            highlights_text: None,
        }
    }

    /// 设置标签的次要文本,次要文本将以 `muted` 颜色显示在主文本之后。
    pub fn secondary(mut self, secondary: impl Into<SharedString>) -> Self {
        self.secondary = Some(secondary.into());
        self
    }

    /// 设置是否掩码显示标签文本。
    pub fn masked(mut self, masked: bool) -> Self {
        self.masked = masked;
        self
    }

    /// 设置标签中要高亮的匹配文本。
    pub fn highlights(mut self, text: impl Into<HighlightsMatch>) -> Self {
        self.highlights_text = Some(text.into());
        self
    }

    fn full_text(&self) -> SharedString {
        match &self.secondary {
            Some(secondary) => format!("{} {}", self.label, secondary).into(),
            None => self.label.clone(),
        }
    }

    fn highlight_ranges(&self, total_length: usize) -> Vec<Range<usize>> {
        let mut ranges = Vec::new();
        let full_text = self.full_text();

        if self.secondary.is_some() {
            ranges.push(0..self.label.len());
            ranges.push(self.label.len()..total_length);
        }

        if let Some(matched) = &self.highlights_text {
            let matched_str = matched.as_str();
            if !matched_str.is_empty() {
                // 大小写不敏感按字符逐个比较,避免 `to_lowercase()` 整串后
                // 字节偏移漂移(`İ` 等小写后字节数变化,旧实现以小写串偏移
                // 切原串 + 按字节 `+1` 步进,中文必 panic,见 search_panel)。
                let query_lower: Vec<char> = matched_str.to_lowercase().chars().collect();
                if matched.is_prefix() {
                    // 对于前缀匹配,只检查文本是否以搜索词开头
                    if let Some(matched_len) = insensitive_prefix_len(&full_text, &query_lower) {
                        ranges.push(0..matched_len.min(full_text.len()));
                    }
                } else {
                    // 对于全文匹配,查找所有出现的位置(`search_start` 恒为字符边界;
                    // 步进按首字符字节数,保留重叠匹配语义,如 `aaaa` 搜 `aa` 得 0..2/1..3/2..4)。
                    let mut search_start = 0;
                    while search_start < full_text.len() {
                        let rest = full_text.get(search_start..).unwrap_or("");
                        let Some(first_len) = rest.chars().next().map(|c| c.len_utf8()) else {
                            break;
                        };
                        if let Some(matched_len) = insensitive_prefix_len(rest, &query_lower) {
                            let match_start = search_start;
                            let match_end = match_start + matched_len;
                            if match_end <= full_text.len() {
                                ranges.push(match_start..match_end);
                            }
                        }
                        search_start += first_len;
                        while search_start < full_text.len()
                            && !full_text.is_char_boundary(search_start)
                        {
                            search_start += 1;
                        }
                    }
                }
            }
        }

        ranges
    }

    fn measure_highlights(
        &self,
        length: usize,
        cx: &mut App,
    ) -> Option<Vec<(Range<usize>, HighlightStyle)>> {
        let ranges = self.highlight_ranges(length);
        if ranges.is_empty() {
            return None;
        }

        let mut highlights = Vec::new();
        let mut highlight_ranges_added = 0;

        if self.secondary.is_some() {
            highlights.push((ranges[0].clone(), HighlightStyle::default()));
            highlights.push((
                ranges[1].clone(),
                HighlightStyle {
                    color: Some(cx.theme().muted_foreground),
                    ..Default::default()
                },
            ));
            highlight_ranges_added = 2;
        }

        for range in ranges.iter().skip(highlight_ranges_added) {
            highlights.push((
                range.clone(),
                HighlightStyle {
                    color: Some(cx.theme().blue),
                    ..Default::default()
                },
            ));
        }

        Some(crate::combine_highlights(vec![], highlights).collect())
    }
}

impl Styled for Label {
    fn style(&mut self) -> &mut crate::StyleRefinement {
        &mut self.style
    }
}

/// 大小写不敏感前缀匹配:`rest` 以 `query_lower`(已按字符小写)开头时,
/// 返回原串中匹配部分的字节长度(小写可能改变字节数,按字符逐个累加还原)。
/// 与 `search_panel::literal_insensitive_prefix_len` 同款,避免整串
/// `to_lowercase()` 后偏移漂移切错中文。
fn insensitive_prefix_len(rest: &str, query_lower: &[char]) -> Option<usize> {
    if query_lower.is_empty() {
        return None;
    }
    let mut chars = rest.chars();
    let mut orig_consumed = 0usize;
    let mut qi = 0usize;
    while qi < query_lower.len() {
        let Some(c) = chars.next() else {
            return None;
        };
        orig_consumed += c.len_utf8();
        let mut lowered = c.to_lowercase();
        while let Some(lc) = lowered.next() {
            if qi >= query_lower.len() || lc != query_lower[qi] {
                return None;
            }
            qi += 1;
        }
    }
    Some(orig_consumed)
}

impl RenderOnce for Label {
    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
        let mut text = self.full_text();
        let chars_count = text.chars().count();

        if self.masked {
            text = SharedString::from(MASKED.repeat(chars_count))
        };

        let highlights = self.measure_highlights(text.len(), cx);

        div()
            .line_height(rems(1.25))
            .text_color(cx.theme().foreground)
            .refine_style(&self.style)
            .child(
                StyledText::new(&text).when_some(highlights, |this, hl| this.with_highlights(hl)),
            )
    }
}

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

    #[test]
    fn test_highlight_ranges() {
        // 基本功能

        // 无高亮
        let label = Label::new("Hello World");
        let result = label.highlight_ranges("Hello World".len());
        assert_eq!(result, Vec::<Range<usize>>::new());

        // 仅次要文本区间
        let label = Label::new("Hello").secondary("World");
        let total_length = "Hello World".len();
        let result = label.highlight_ranges(total_length);
        assert_eq!(result.len(), 2);
        assert_eq!(result[0], 0..5); // "Hello"
        assert_eq!(result[1], 5..11); // " World"

        // 文本高亮

        // 不区分大小写的单个匹配
        let label = Label::new("Hello World").highlights("WORLD");
        let result = label.highlight_ranges("Hello World".len());
        assert_eq!(result.len(), 1);
        assert_eq!(result[0], 6..11); // "World"

        // 多个匹配
        let label = Label::new("Hello Hello Hello").highlights("Hello");
        let result = label.highlight_ranges("Hello Hello Hello".len());
        assert_eq!(result.len(), 3);
        assert_eq!(result[0], 0..5); // 第一个 "Hello"
        assert_eq!(result[1], 6..11); // 第二个 "Hello"
        assert_eq!(result[2], 12..17); // 第三个 "Hello"

        // 无匹配与空搜索词
        let label = Label::new("Hello World").highlights("xyz");
        let result = label.highlight_ranges("Hello World".len());
        assert_eq!(result, Vec::<Range<usize>>::new());

        let label = Label::new("Hello World").highlights("");
        let result = label.highlight_ranges("Hello World".len());
        assert_eq!(result, Vec::<Range<usize>>::new());

        // 组合功能

        // 次要文本 + 主文本高亮
        let label = Label::new("Hello").secondary("World").highlights("llo");
        let total_length = "Hello World".len();
        let result = label.highlight_ranges(total_length);
        assert_eq!(result.len(), 3);
        assert_eq!(result[0], 0..5); // 主文本区间
        assert_eq!(result[1], 5..11); // 次要文本区间
        assert_eq!(result[2], 2..5); // 主文本中的 "llo"

        // 次要文本中的高亮
        let label = Label::new("Hello").secondary("World").highlights("World");
        let total_length = "Hello World".len();
        let result = label.highlight_ranges(total_length);
        assert_eq!(result.len(), 3);
        assert_eq!(result[0], 0..5); // 主文本区间
        assert_eq!(result[1], 5..11); // 次要文本区间
        assert_eq!(result[2], 6..11); // 次要文本中的 "World"

        // 跨边界高亮
        let label = Label::new("Hello").secondary("World").highlights("o W");
        let total_length = "Hello World".len();
        let result = label.highlight_ranges(total_length);
        assert_eq!(result.len(), 3);
        assert_eq!(result[0], 0..5); // 主文本区间
        assert_eq!(result[1], 5..11); // 次要文本区间
        assert_eq!(result[2], 4..7); // 跨边界的 "o W"

        // 边界情况

        // 重叠匹配
        let label = Label::new("aaaa").highlights("aa");
        let result = label.highlight_ranges("aaaa".len());
        assert!(result.len() >= 2);
        assert_eq!(result[0], 0..2); // 第一个 "aa"
        assert_eq!(result[1], 1..3); // 重叠的 "aa"

        // Unicode 文本
        let label = Label::new("你好世界,Hello World").highlights("世界");
        let result = label.highlight_ranges("你好世界,Hello World".len());
        assert_eq!(result.len(), 1);
        let text = "你好世界,Hello World";
        let start = text.find("世界").unwrap();
        let end = start + "世界".len();
        assert_eq!(result[0], start..end);
    }

    #[test]
    fn test_highlight_ranges_prefix() {
        // 测试前缀匹配 - 应只匹配第一次出现
        let label = Label::new("aaaa").highlights(HighlightsMatch::Prefix("aa".into()));
        let result = label.highlight_ranges("aaaa".len());
        assert_eq!(result.len(), 1);
        assert_eq!(result[0], 0..2); // 仅第一个 "aa"

        // 测试前缀与全文匹配行为
        let label_full =
            Label::new("Hello Hello").highlights(HighlightsMatch::Full("Hello".into()));
        let result_full = label_full.highlight_ranges("Hello Hello".len());
        assert_eq!(result_full.len(), 2); // 两个 "Hello" 都匹配

        let label_prefix =
            Label::new("Hello Hello").highlights(HighlightsMatch::Prefix("Hello".into()));
        let result_prefix = label_prefix.highlight_ranges("Hello Hello".len());
        assert_eq!(result_prefix.len(), 1); // 仅第一个 "Hello"
        assert_eq!(result_prefix[0], 0..5);

        // 测试不区分大小写的前缀匹配
        let label =
            Label::new("Hello hello HELLO").highlights(HighlightsMatch::Prefix("hello".into()));
        let result = label.highlight_ranges("Hello hello HELLO".len());
        assert_eq!(result.len(), 1);
        assert_eq!(result[0], 0..5); // 第一个 "Hello"(不区分大小写)

        // 测试无匹配的前缀
        let label = Label::new("Hello World").highlights(HighlightsMatch::Prefix("xyz".into()));
        let result = label.highlight_ranges("Hello World".len());
        assert_eq!(result.len(), 0);

        // 测试空前缀
        let label = Label::new("Hello World").highlights(HighlightsMatch::Prefix("".into()));
        let result = label.highlight_ranges("Hello World".len());
        assert_eq!(result.len(), 0);

        // 带次要文本的前缀匹配 - 在主文本中匹配
        let label = Label::new("Hello")
            .secondary("Hello World")
            .highlights(HighlightsMatch::Prefix("Hello".into()));
        let total_length = "Hello Hello World".len();
        let result = label.highlight_ranges(total_length);
        assert_eq!(result.len(), 3); // 2 个次要文本 + 1 个前缀匹配
        assert_eq!(result[0], 0..5); // 主文本区间
        assert_eq!(result[1], 5..17); // 次要文本区间
        assert_eq!(result[2], 0..5); // 主文本中第一个 "Hello" 前缀匹配

        // 带次要文本的前缀匹配 - 跨边界不匹配
        let label = Label::new("abc")
            .secondary("def abc def")
            .highlights(HighlightsMatch::Prefix("abc".into()));
        let total_length = "abc def abc def".len();
        let result = label.highlight_ranges(total_length);
        assert_eq!(result.len(), 3); // 2 个次要文本 + 1 个前缀匹配
        assert_eq!(result[0], 0..3); // 主文本区间
        assert_eq!(result[1], 3..15); // 次要文本区间
        assert_eq!(result[2], 0..3); // "abc" 在全文开头匹配

        // 测试 Unicode 字符的前缀匹配
        let label = Label::new("你好世界你好").highlights(HighlightsMatch::Prefix("你好".into()));
        let result = label.highlight_ranges("你好世界你好".len());
        assert_eq!(result.len(), 1);
        assert_eq!(result[0], 0..6); // 第一个 "你好"(UTF-8 中 6 字节)

        // 测试重叠模式的前缀匹配
        let label = Label::new("abababab").highlights(HighlightsMatch::Prefix("abab".into()));
        let result = label.highlight_ranges("abababab".len());
        assert_eq!(result.len(), 1);
        assert_eq!(result[0], 0..4); // 仅第一个 "abab"

        // 前缀匹配在不同位置("Hello" 不在开头则不匹配)
        let label =
            Label::new("xyz Hello abc Hello").highlights(HighlightsMatch::Prefix("Hello".into()));
        let result = label.highlight_ranges("xyz Hello abc Hello".len());
        assert_eq!(result.len(), 0); // 无匹配,因为 "Hello" 不在开头

        // 测试 is_prefix 方法
        let prefix_match = HighlightsMatch::Prefix("test".into());
        let full_match = HighlightsMatch::Full("test".into());
        assert!(prefix_match.is_prefix());
        assert!(!full_match.is_prefix());

        // 测试 as_str 方法
        let prefix_match = HighlightsMatch::Prefix("test".into());
        assert_eq!(prefix_match.as_str(), "test");
    }
}