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 {
pub(crate) fn android_input_type(self) -> i32 {
match self {
TextInputMode::Integer => 0x2 | 0x1000,
TextInputMode::Decimal => 0x2 | 0x1000 | 0x2000,
TextInputMode::Any => 0x1,
}
}
}
pub trait NativeNumber: Copy {
fn from_f64(v: f64) -> Self;
fn to_f64(self) -> f64;
fn input_mode() -> TextInputMode;
fn default_min() -> f64;
fn default_max() -> f64;
}
macro_rules! impl_native_number_int {
($($t:ty),* $(,)?) => {$(
impl NativeNumber for $t {
fn from_f64(v: f64) -> Self {
v.round() as $t }
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
}
}
pub struct NativeTextEdit<'a> {
id: egui::Id,
text: &'a mut String,
title: String,
hint: String,
}
impl<'a> NativeTextEdit<'a> {
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
}
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 {
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
}
}
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> {
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(),
}
}
pub fn speed(mut self, speed: T) -> Self {
self.speed = speed.to_f64();
self
}
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
}
pub fn max_decimals(mut self, n: usize) -> Self {
self.max_decimals = n;
self
}
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
}
fn clamp(&self, v: f64) -> f64 {
v.clamp(
self.min.unwrap_or_else(T::default_min),
self.max.unwrap_or_else(T::default_max),
)
}
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()),
);
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));
}
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
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct UnityTextSegment {
pub text: String,
pub color: Option<Color32>,
pub size: Option<f32>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strikethrough: bool,
}
const RICH_TEXT_CACHE_MAX: usize = 256;
static RICH_TEXT_CACHE: Mutex<Option<HashMap<String, Vec<UnityTextSegment>>>> = Mutex::new(None);
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
}
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
};
if seg.bold {
fmt.color = lighten(fmt.color, 0.35);
}
job.append(&seg.text, 0.0, fmt);
}
job
}
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();
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; }
} 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
}
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);
}
}