menu-ui 0.1.0

Android egui 内置菜单框架:EGL/GL 渲染、JNI 输入桥接、原生文本编辑框与图片选择
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
use crate::state::{TEXT_BRIDGE, TextEditRequest};
use egui::text::{LayoutJob, TextFormat};
use egui::{Color32, Id, Stroke};
use std::collections::HashMap;
use std::sync::Mutex;

/// 原生编辑框的输入模式。
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum TextInputMode {
    /// 只允许输入整数(数字键盘,带负号)。
    Integer,
    /// 允许输入小数(数字键盘 + 小数点 + 负号)。
    Decimal,
    /// 允许任意文本。
    Any,
}

impl TextInputMode {
    /// 映射到 Android `InputType` 的取值(见 `android.text.InputType`):
    /// - `TYPE_CLASS_TEXT = 0x1`
    /// - `TYPE_CLASS_NUMBER = 0x2`
    /// - `TYPE_NUMBER_FLAG_SIGNED = 0x1000`(数字键盘带负号)
    /// - `TYPE_NUMBER_FLAG_DECIMAL = 0x2000`
    pub(crate) fn android_input_type(self) -> i32 {
        match self {
            // 整数/小数键盘都带负号(允许输入负数)。
            TextInputMode::Integer => 0x2 | 0x1000,
            TextInputMode::Decimal => 0x2 | 0x1000 | 0x2000,
            TextInputMode::Any => 0x1,
        }
    }
}

/// 数值类型转换(供 [`NativeNumberInput`] 泛型使用)。
///
/// 支持全部整数类型(`i8/i16/i32/i64/i128/isize/u8/u16/u32/u64/u128/usize`)
/// 与浮点类型(`f64`、`f128`)。
pub trait NativeNumber: Copy {
    /// 由 f64 转回自身(整数取整并饱和转换)。
    fn from_f64(v: f64) -> Self;
    /// 转成 f64 处理(超出 f64 范围的值会饱和/截断)。
    fn to_f64(self) -> f64;
    /// 原生编辑框输入模式(整数 → 整数键盘;浮点 → 小数键盘)。
    fn input_mode() -> TextInputMode;
    /// 类型的默认最小值(未设置 `range`/`min` 时的 clamp 下界)。
    fn default_min() -> f64;
    /// 类型的默认最大值(未设置 `range`/`max` 时的 clamp 上界)。
    fn default_max() -> f64;
}

/// 为所有整数类型批量实现 [`NativeNumber`]。
macro_rules! impl_native_number_int {
    ($($t:ty),* $(,)?) => {$(
        impl NativeNumber for $t {
            fn from_f64(v: f64) -> Self {
                v.round() as $t // as 为饱和转换:越界 → MIN/MAX,NaN → 0
            }
            fn to_f64(self) -> f64 {
                self as f64
            }
            fn input_mode() -> TextInputMode {
                TextInputMode::Integer
            }
            fn default_min() -> f64 {
                <$t>::MIN as f64
            }
            fn default_max() -> f64 {
                <$t>::MAX as f64
            }
        }
    )*};
}

impl_native_number_int!(i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize);

impl NativeNumber for f64 {
    fn from_f64(v: f64) -> Self {
        v
    }
    fn to_f64(self) -> f64 {
        self
    }
    fn input_mode() -> TextInputMode {
        TextInputMode::Decimal
    }
    fn default_min() -> f64 {
        f64::MIN
    }
    fn default_max() -> f64 {
        f64::MAX
    }
}
impl NativeNumber for f32 {
    fn from_f64(v: f64) -> Self {
        v as f32
    }
    fn to_f64(self) -> f64 {
        self as f64
    }
    fn input_mode() -> TextInputMode {
        TextInputMode::Decimal
    }
    fn default_min() -> f64 {
        f32::MIN as f64
    }
    fn default_max() -> f64 {
        f32::MAX as f64
    }
}

// 说明:`f128` 类型目前在 stable Rust(含本项目的 1.98.0 / Android 目标)
// 仍未稳定(`E0658`,需 nightly 的 `#![feature(f128)]`),因此暂不实现。
// 待稳定后只需加上以下实现(一行接入,其余逻辑自动泛型生效):
//
// impl NativeNumber for f128 {
//     fn from_f64(v: f64) -> Self { v as f128 }
//     fn to_f64(self) -> f64 { self as f64 } // 超出 f64 范围饱和为 ±inf
//     fn input_mode() -> TextInputMode { TextInputMode::Decimal }
//     fn default_min() -> f64 { f64::NEG_INFINITY } // f128 值域远超 f64
//     fn default_max() -> f64 { f64::INFINITY }
// }

// ===========================================================================
// NativeTextEdit:点击弹出 Android 原生编辑框的文本输入控件(egui 风格)
// ===========================================================================

/// 文本输入控件:显示 `TextEdit`,点击后弹出 Android 原生编辑框编辑,
/// 「完成」后结果按控件 id 回填到 `text`(任意文本模式)。
///
/// egui 风格链式用法:
/// ```rust,ignore
/// ui.add(
///     NativeTextEdit::new(egui::Id::new("name_edit"), &mut demo.name)
///         .title("编辑姓名")
///         .hint("请输入姓名"),
/// );
/// ```
pub struct NativeTextEdit<'a> {
    id: egui::Id,
    text: &'a mut String,
    title: String,
    hint: String,
}

impl<'a> NativeTextEdit<'a> {
    /// 创建一个文本输入控件(id 用于结果回填分发,需全局唯一)。
    pub fn new<ID:Into<Id>>(id: ID, text: &'a mut String) -> Self {
        Self {
            id:id.into(),
            text,
            title: "编辑文本".to_owned(),
            hint: String::new(),
        }
    }

    /// 设置原生编辑框对话框标题。
    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.title = title.into();
        self
    }

    /// 设置原生编辑框输入框的占位提示(hint)。
    pub fn hint(mut self, hint: impl Into<String>) -> Self {
        self.hint = hint.into();
        self
    }
}

impl egui::Widget for NativeTextEdit<'_> {
    fn ui(self, ui: &mut egui::Ui) -> egui::Response {
        // 桥接状态全程持锁(本控件内的短临界区),结果按控件 id 分发。
        let mut bridge = TEXT_BRIDGE.lock().unwrap();

        // 消费本控件自己的编辑结果。
        let mut i = 0;
        while i < bridge.results.len() {
            if bridge.results[i].0 == self.id.value() {
                if let Some((_, new_text)) = bridge.results.remove(i) {
                    *self.text = new_text;
                }
            } else {
                i += 1;
            }
        }

        let response = ui.add(
            egui::TextEdit::singleline(self.text)
                .id(self.id)
                .hint_text(&self.hint),
        );
        if response.clicked() && !bridge.dialog_open && bridge.pending_request.is_none() {
            bridge.pending_request = Some(TextEditRequest {
                widget_id: self.id.value(),
                initial_text: self.text.clone(),
                title: self.title,
                hint: self.hint,
                input_type: TextInputMode::Any.android_input_type(),
            });
        }
        response
    }
}

// ===========================================================================
// NativeNumberInput:拖动调值 + 点击弹 Android 原生编辑框的数值控件
// ===========================================================================

/// 数值输入控件:**拖动调值**(`speed`/`range`/decimals/prefix/suffix,
/// 同 `egui::DragValue` 手感)+ **点击弹出 Android 原生编辑框键入**
/// (「完成」后解析回填,解析失败保持原值)。
///
/// 支持整数(`i32`)与小数(`f64`):通过 [`NativeNumber`] 泛型区分;
/// 输入模式由类型推断(i32 → 整数键盘,f64 → 小数键盘)。
///
/// egui 风格链式用法:
/// ```rust,ignore
/// ui.add(
///     NativeNumberInput::new(egui::Id::new("rate_edit"), &mut self.my_float)
///         .speed(0.05)
///         .range(0.0..=10.0)
///         .prefix("Value: ")
///         .suffix("x")
///         .fixed_decimals(2),
/// );
/// ```
pub struct NativeNumberInput<'a, T: NativeNumber> {
    id: egui::Id,
    value: &'a mut T,
    min: Option<f64>,
    max: Option<f64>,
    speed: f64,
    prefix: String,
    suffix: String,
    min_decimals: usize,
    max_decimals: usize,
    fixed_decimals: Option<usize>,
    title: String,
    hint: String,
}

impl<'a, T: NativeNumber> NativeNumberInput<'a, T> {
    /// 创建一个数值输入控件(id 用于结果回填分发,需全局唯一)。
    pub fn new<ID:Into<Id>>(id: ID, value: &'a mut T) -> Self {
        Self {
            id:id.into(),
            value,
            min: None,
            max: None,
            speed: 1.0,
            prefix: String::new(),
            suffix: String::new(),
            min_decimals: 0,
            max_decimals: 2,
            fixed_decimals: None,
            title: "编辑数值".to_owned(),
            hint: String::new(),
        }
    }

    /// 拖动速度:每拖动 1 point 的数值变化量(`T` 类型,如 `1` 或 `0.05`)。
    pub fn speed(mut self, speed: T) -> Self {
        self.speed = speed.to_f64();
        self
    }

    /// 数值范围限制(拖动与输入回填都会 clamp),同 `egui::DragValue::range`。
    pub fn range(mut self, range: std::ops::RangeInclusive<T>) -> Self {
        self.min = Some(range.start().to_f64());
        self.max = Some(range.end().to_f64());
        self
    }

    /// 数值最小值。
    pub fn min(mut self, min: T) -> Self {
        self.min = Some(min.to_f64());
        self
    }

    /// 数值最大值。
    pub fn max(mut self, max: T) -> Self {
        self.max = Some(max.to_f64());
        self
    }

    /// 显示前缀。
    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
        self.prefix = prefix.into();
        self
    }

    /// 显示后缀。
    pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
        self.suffix = suffix.into();
        self
    }

    /// 最少显示小数位(不足补零)。
    pub fn min_decimals(mut self, n: usize) -> Self {
        self.min_decimals = n;
        self
    }

    /// 最多显示小数位(0 表示只显示整数)。
    pub fn max_decimals(mut self, n: usize) -> Self {
        self.max_decimals = n;
        self
    }

    /// 固定小数位(优先于 min/max_decimals)。
    pub fn fixed_decimals(mut self, n: usize) -> Self {
        self.fixed_decimals = Some(n);
        self
    }

    /// 原生编辑框对话框标题。
    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.title = title.into();
        self
    }

    /// 原生编辑框输入框占位提示。
    pub fn hint(mut self, hint: impl Into<String>) -> Self {
        self.hint = hint.into();
        self
    }

    /// 把值 clamp 到 [min, max];未设置 min/max 时使用**类型自身的默认
    /// 最小值/最大值**(如 `i32` → -2^31..=2^31-1,`f64` → 完整 f64 值域)。
    fn clamp(&self, v: f64) -> f64 {
        v.clamp(
            self.min.unwrap_or_else(T::default_min),
            self.max.unwrap_or_else(T::default_max),
        )
    }

    /// 按配置格式化显示文本(prefix + 数值 + suffix)。
    ///
    /// 注意:尾零只对**小数部分**裁剪,整数部分的零不能动
    /// (`"0"` 不能被吞成空串、`"10"` 不能变 `"1"`)。
    fn format(&self, value: f64) -> String {
        let body = if let Some(fd) = self.fixed_decimals {
            format!("{value:.fd$}")
        } else {
            let width = self.max_decimals.max(self.min_decimals);
            let raw = format!("{value:.width$}");
            // 只在存在小数点时裁剪小数尾零(并去掉全零小数部分)。
            let mut s = raw;
            if let Some(pos) = s.find('.') {
                let int_part = &s[..pos];
                let frac = s[pos + 1..].trim_end_matches('0');
                s = if frac.is_empty() {
                    int_part.to_owned()
                } else {
                    format!("{int_part}.{frac}")
                };
            }
            // 最少小数位:不足补零(含“整数+小数点+零”情形)。
            if self.min_decimals > 0 {
                if let Some((int_part, frac)) = s.split_once('.') {
                    if frac.len() < self.min_decimals {
                        s = format!(
                            "{int_part}.{}{}",
                            frac,
                            "0".repeat(self.min_decimals - frac.len())
                        );
                    }
                } else {
                    s = format!("{}.{}", s, "0".repeat(self.min_decimals));
                }
            }
            s
        };
        format!("{}{}{}", self.prefix, body, self.suffix)
    }
}

impl<T: NativeNumber> egui::Widget for NativeNumberInput<'_, T> {
    fn ui(self, ui: &mut egui::Ui) -> egui::Response {
        // 桥接状态全程持锁(本控件内的短临界区)。
        let mut bridge = TEXT_BRIDGE.lock().unwrap();

        // 消费本控件自己的编辑结果(解析成数值;解析失败保持原值)。
        let mut i = 0;
        while i < bridge.results.len() {
            if bridge.results[i].0 == self.id.value() {
                if let Some((_, new_text)) = bridge.results.remove(i) {
                    if let Ok(n) = new_text.trim().parse::<f64>() {
                        *self.value = T::from_f64(self.clamp(n));
                    }
                }
            } else {
                i += 1;
            }
        }

        // 显示:可拖动、可点击的数值框。
        let response = ui.add(
            egui::Button::new(self.format(self.value.to_f64()))
                .min_size(egui::vec2(72.0, ui.text_style_height(&egui::TextStyle::Body)))
                .sense(egui::Sense::click_and_drag()),
        );
        // 拖动调值:水平拖动,变化量 = x 位移 * speed,然后 clamp 到 [min, max]。
        if response.dragged() {
            let delta = response.drag_delta().x as f64 * self.speed;
            *self.value = T::from_f64(self.clamp(self.value.to_f64() + delta));
        }
        // 点击:弹出 Android 原生编辑框输入(回显当前原始数值)。
        if response.clicked() && !bridge.dialog_open && bridge.pending_request.is_none() {
            bridge.pending_request = Some(TextEditRequest {
                widget_id: self.id.value(),
                initial_text: self.value.to_f64().to_string(),
                title: self.title,
                hint: self.hint,
                input_type: T::input_mode().android_input_type(),
            });
        }
        response
    }
}

// ===========================================================================
// Unity 富文本(Rich Text)解析渲染 + 解析缓存
// ===========================================================================

/// Unity 富文本解析出的文本片段(**相对样式**,与渲染时的默认样式无关)。
///
/// `color`/`size` 为 `None` 表示"继承默认";`bold`/`italic`/`underline`/
/// `strikethrough` 是布尔开关。因为与默认样式解耦,解析结果可以安全缓存:
/// 同一输入文本只解析一次,渲染时再按当时的默认样式生成 [`LayoutJob`]。
#[derive(Clone, Debug, PartialEq)]
pub struct UnityTextSegment {
    /// 纯文本内容(不含任何标签)。
    pub text: String,
    /// 显式颜色(`<color=...>`),`None` 表示继承默认色。
    pub color: Option<Color32>,
    /// 显式字号(`<size=N>`,单位与 egui point 一致),`None` 表示继承默认。
    pub size: Option<f32>,
    /// `<b>` 粗体(当前字体无粗体变体时用提亮颜色近似)。
    pub bold: bool,
    /// `<i>` 斜体。
    pub italic: bool,
    /// `<u>` 下划线。
    pub underline: bool,
    /// `<s>` 删除线。
    pub strikethrough: bool,
}

/// 解析结果缓存上限(超出后整体清空;UI 文本量小,简单策略足够)。
const RICH_TEXT_CACHE_MAX: usize = 256;
static RICH_TEXT_CACHE: Mutex<Option<HashMap<String, Vec<UnityTextSegment>>>> = Mutex::new(None);

/// 把 Unity 富文本解析为片段列表。
///
/// **缓存**:输入文本不变时直接返回缓存结果,不做重复解析(每帧渲染同一
/// 文本只解析一次;解析本身也是 O(n) 扫描,开销可忽略)。
pub fn parse_unity_rich_text(src: &str) -> Vec<UnityTextSegment> {
    let mut guard = RICH_TEXT_CACHE.lock().unwrap();
    let cache = guard.get_or_insert_with(HashMap::new);
    if let Some(hit) = cache.get(src) {
        return hit.clone();
    }
    let parsed = parse_rich_text(src);
    if cache.len() >= RICH_TEXT_CACHE_MAX {
        cache.clear();
    }
    cache.insert(src.to_owned(), parsed.clone());
    parsed
}

/// 把 Unity 富文本渲染为 egui 布局任务(应用 `default` 作为未指定样式的基础)。
///
/// 用法(menu 的 UI 代码):
/// ```rust,ignore
/// let fmt = egui::text::TextFormat {
///     font_id: egui::FontId::proportional(16.0),
///     color: ui.visuals().text_color(),
///     ..Default::default()
/// };
/// ui.label(menu_ui::unity_rich_text("<color=#FF5500>橙色</color> <b>加粗</b>", &fmt));
/// ```
///
/// 支持的标签(Unity UGUI/TMPro 子集):`<b>` `<i>` `<u>` `<s>`
/// `<size=N>` `<color=#RRGGBB[AA]|颜色名>`(及对应 `</...>`)。
/// 未知/格式错误的标签按字面显示;未闭合的开始标签作用到文本末尾。
pub fn unity_rich_text(src: &str) -> LayoutJob {
    let segments = parse_unity_rich_text(src);
    let mut job = LayoutJob::default();
    for seg in &segments {
        let mut fmt = TextFormat::default();
        if let Some(c) = seg.color {
            fmt.color = c;
        }
        if let Some(s) = seg.size {
            fmt.font_id.size = s;
        }
        fmt.italics = seg.italic;
        fmt.underline = if seg.underline {
            Stroke::new(1.0, fmt.color)
        } else {
            Stroke::NONE
        };
        fmt.strikethrough = if seg.strikethrough {
            Stroke::new(1.0, fmt.color)
        } else {
            Stroke::NONE
        };
        // `<b>`:egui 无粗体字体变体(框架只加载常规字体),用提亮颜色近似。
        if seg.bold {
            fmt.color = lighten(fmt.color, 0.35);
        }
        job.append(&seg.text, 0.0, fmt);
    }
    job
}

/// 颜色提亮 `amount`(0..1,1 = 全白);用于粗体近似。
fn lighten(c: Color32, amount: f32) -> Color32 {
    let t = amount.clamp(0.0, 1.0);
    let f = |v: u8| (v as f32 + (255.0 - v as f32) * t).round() as u8;
    Color32::from_rgb(f(c.r()), f(c.g()), f(c.b()))
}

/// 实际解析(无缓存,纯函数)。
fn parse_rich_text(src: &str) -> Vec<UnityTextSegment> {
    // 格式栈:栈底是"默认",每个开始标签压入新快照。
    #[derive(Clone, Debug)]
    struct Fmt {
        color: Option<Color32>,
        size: Option<f32>,
        bold: bool,
        italic: bool,
        underline: bool,
        strikethrough: bool,
    }
    impl Default for Fmt {
        fn default() -> Self {
            Self {
                color: None,
                size: None,
                bold: false,
                italic: false,
                underline: false,
                strikethrough: false,
            }
        }
    }

    /// 追加纯文本到片段列表(与上一段格式相同则合并,减少片段数)。
    fn push_text(
        segments: &mut Vec<UnityTextSegment>,
        text: &str,
        fmt: &Fmt,
    ) {
        if text.is_empty() {
            return;
        }
        let seg = UnityTextSegment {
            text: text.to_owned(),
            color: fmt.color,
            size: fmt.size,
            bold: fmt.bold,
            italic: fmt.italic,
            underline: fmt.underline,
            strikethrough: fmt.strikethrough,
        };
        if let Some(last) = segments.last_mut() {
            if last.color == seg.color
                && last.size == seg.size
                && last.bold == seg.bold
                && last.italic == seg.italic
                && last.underline == seg.underline
                && last.strikethrough == seg.strikethrough
            {
                last.text.push_str(text);
                return;
            }
        }
        segments.push(seg);
    }

    /// 结束标签:弹出栈顶一层(容错:不匹配的结束标签也按最近层弹出,
    /// 栈底默认层不弹)。
    fn close_tag(stack: &mut Vec<Fmt>) {
        if stack.len() > 1 {
            stack.pop();
        }
    }

    let mut segments: Vec<UnityTextSegment> = Vec::new();
    let mut stack: Vec<Fmt> = vec![Fmt::default()];
    let bytes = src.as_bytes();
    let mut i = 0usize;
    let mut plain_start = 0usize;

    while i < bytes.len() {
        if bytes[i] != b'<' {
            i += 1;
            continue;
        }
        // 找到闭合 '>'
        let Some(rel) = bytes[i + 1..].iter().position(|&b| b == b'>') else {
            break; // 无闭合:剩余全部按字面
        };
        let tag_end = i + 1 + rel;
        let tag = src[i + 1..tag_end].trim();
        // 先 flush 标签前的纯文本
        push_text(&mut segments, &src[plain_start..i], stack.last().unwrap());

        // 识别标签;未知/非法标签按字面显示
        let mut handled = true;
        let fmt = stack.last().unwrap().clone();
        match tag {
            "b" => {
                stack.push(Fmt { bold: true, ..fmt });
            }
            "/b" => close_tag(&mut stack),
            "i" => {
                stack.push(Fmt { italic: true, ..fmt });
            }
            "/i" => close_tag(&mut stack),
            "u" => {
                stack.push(Fmt { underline: true, ..fmt });
            }
            "/u" => close_tag(&mut stack),
            "s" => {
                stack.push(Fmt { strikethrough: true, ..fmt });
            }
            "/s" => close_tag(&mut stack),
            "/size" => close_tag(&mut stack),
            "/color" => close_tag(&mut stack),
            _ => {
                if let Some(v) = tag.strip_prefix("size=") {
                    let v = v.trim().trim_end_matches("px").trim();
                    if let Ok(n) = v.parse::<f32>() {
                        if n > 0.0 {
                            stack.push(Fmt { size: Some(n), ..fmt });
                        }
                    } else {
                        handled = false; // 非法 size:按字面
                    }
                } else if let Some(v) = tag.strip_prefix("color=") {
                    if let Some(c) = parse_color(v.trim()) {
                        stack.push(Fmt { color: Some(c), ..fmt });
                    } else {
                        handled = false; // 非法颜色:按字面
                    }
                } else {
                    handled = false; // 未知标签:按字面
                }
            }
        }
        if !handled {
            push_text(&mut segments, &src[i..=tag_end], stack.last().unwrap());
        }
        i = tag_end + 1;
        plain_start = i;
    }
    if plain_start < bytes.len() {
        push_text(&mut segments, &src[plain_start..], stack.last().unwrap());
    }
    segments
}

/// 解析 Unity 颜色:`#RRGGBB` / `#RRGGBBAA` / 常用颜色名。
fn parse_color(s: &str) -> Option<Color32> {
    if let Some(hex) = s.strip_prefix('#') {
        let hex = hex.trim();
        let v = u32::from_str_radix(hex, 16).ok()?;
        return match hex.len() {
            6 => Some(Color32::from_rgb(
                ((v >> 16) & 0xff) as u8,
                ((v >> 8) & 0xff) as u8,
                (v & 0xff) as u8,
            )),
            8 => Some(Color32::from_rgba_unmultiplied(
                ((v >> 24) & 0xff) as u8,
                ((v >> 16) & 0xff) as u8,
                ((v >> 8) & 0xff) as u8,
                (v & 0xff) as u8,
            )),
            _ => None,
        };
    }
    Some(match s.to_ascii_lowercase().as_str() {
        "black" => Color32::BLACK,
        "blue" => Color32::BLUE,
        "green" => Color32::GREEN,
        "orange" => Color32::from_rgb(255, 165, 0),
        "purple" => Color32::from_rgb(160, 32, 240),
        "red" => Color32::RED,
        "white" => Color32::WHITE,
        "yellow" => Color32::YELLOW,
        "cyan" => Color32::from_rgb(0, 255, 255),
        "magenta" => Color32::from_rgb(255, 0, 255),
        "gray" | "grey" => Color32::GRAY,
        "pink"=>Color32::from_rgb(0, 246, 246),
        _ => return None,
    })
}

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

    fn segs(src: &str) -> Vec<UnityTextSegment> {
        parse_rich_text(src)
    }

    #[test]
    fn plain_text_single_segment() {
        let s = segs("你好 world");
        assert_eq!(s.len(), 1);
        assert_eq!(s[0].text, "你好 world");
        assert_eq!(s[0].color, None);
        assert!(!s[0].bold);
    }

    #[test]
    fn bold_and_italic() {
        let s = segs("<b>粗</b><i>斜</i>");
        assert_eq!(s.len(), 2);
        assert!(s[0].bold);
        assert!(!s[0].italic);
        assert_eq!(s[0].text, "粗");
        assert!(s[1].italic);
        assert_eq!(s[1].text, "斜");
    }

    #[test]
    fn color_hex_and_name() {
        let s = segs("<color=#FF0000>红</color><color=blue>蓝</color>");
        assert_eq!(s[0].color, Some(Color32::RED));
        assert_eq!(s[1].color, Some(Color32::BLUE));
    }

    #[test]
    fn size_tag() {
        let s = segs("<size=24>大</size>小");
        assert_eq!(s[0].size, Some(24.0));
        assert_eq!(s[1].size, None);
    }

    #[test]
    fn nested_tags() {
        let s = segs("<b><color=red>粗红</color></b>尾");
        assert_eq!(s.len(), 2);
        assert!(s[0].bold);
        assert_eq!(s[0].color, Some(Color32::RED));
        assert_eq!(s[0].text, "粗红");
        assert!(!s[1].bold);
    }

    #[test]
    fn unclosed_tag_extends_to_end() {
        let s = segs("前<color=green>绿到尾");
        assert_eq!(s.len(), 2);
        assert_eq!(s[0].text, "前");
        assert_eq!(s[1].color, Some(Color32::GREEN));
        assert_eq!(s[1].text, "绿到尾");
    }

    #[test]
    fn unknown_tag_shown_literal() {
        let s = segs("<bogus>字面</bogus>");
        assert_eq!(s.len(), 1);
        assert_eq!(s[0].text, "<bogus>字面</bogus>");
    }

    #[test]
    fn cache_returns_same_result() {
        let a = parse_unity_rich_text("<b>x</b>");
        let b = parse_unity_rich_text("<b>x</b>");
        assert_eq!(a, b);
    }
}