mirror of
https://github.com/Runnin4ik/dpi-detector.git
synced 2026-10-19 09:23:17 +00:00
feat(dns,i18n): add missing public DNS endpoints and implement Arabic/Persian text shaping and bidi
This commit is contained in:
parent
a983de446d
commit
65dc96a300
8 changed files with 144 additions and 29 deletions
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -106,6 +106,12 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arabic_reshaper"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb963411bc62b3888e69ad0d821ac03a3176ec7d74c2096bb5555a4cc419545b"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
|
|
@ -444,6 +450,7 @@ dependencies = [
|
|||
name = "dpi-core"
|
||||
version = "4.0.0-rust"
|
||||
dependencies = [
|
||||
"arabic_reshaper",
|
||||
"base64",
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
|
|
@ -460,6 +467,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
"unicode-bidi",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
"winreg",
|
||||
|
|
@ -1936,6 +1944,12 @@ version = "1.20.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ crossterm = "0.28"
|
|||
comfy-table = { version = "7.1", default-features = false, features = ["custom_styling"] }
|
||||
unicode-width = "0.2"
|
||||
rand = "0.8"
|
||||
arabic_reshaper = "0.4.2"
|
||||
unicode-bidi = "0.3"
|
||||
thiserror = "1.0"
|
||||
base64 = "0.22"
|
||||
|
||||
|
|
|
|||
19
config.yml
19
config.yml
|
|
@ -81,9 +81,13 @@ DNS_UDP_SERVERS:
|
|||
- ["76.76.2.0", "ControlD"]
|
||||
- ["194.242.2.2", "Mullvad"]
|
||||
- ["185.228.168.9", "CleanBrowsing"]
|
||||
- ["76.76.19.19", "Alternate DNS"]
|
||||
- ["76.223.122.150", "NextDNS"]
|
||||
- ["185.222.222.222", "DNS.SB"]
|
||||
- ["176.9.93.198", "dnsforge"]
|
||||
- ["8.26.56.26", "Comodo"]
|
||||
- ["84.200.69.80", "DNS Watch"]
|
||||
- ["209.244.0.3", "Level 3"]
|
||||
|
||||
# --- Тест 2: Проверка доступности DNS-серверов ---
|
||||
DNS_AVAILABILITY_DOMAINS:
|
||||
|
|
@ -136,6 +140,19 @@ DNS_AVAILABILITY_SERVERS:
|
|||
- ["45.155.204.190", "Geohide", "udp"]
|
||||
- ["83.220.169.155", "Comss.one", "udp"]
|
||||
- ["212.109.195.93", "Comss.one", "udp"]
|
||||
- ["76.76.19.19", "Alternate DNS", "udp"]
|
||||
- ["76.223.122.150", "Alternate DNS 2", "udp"]
|
||||
- ["8.26.56.26", "Comodo", "udp"]
|
||||
- ["8.20.247.20", "Comodo 2", "udp"]
|
||||
- ["84.200.69.80", "DNS Watch", "udp"]
|
||||
- ["84.200.70.40", "DNS Watch 2", "udp"]
|
||||
- ["209.244.0.3", "Level 3", "udp"]
|
||||
- ["209.244.0.4", "Level 3 2", "udp"]
|
||||
- ["209.224.0.3", "Level 3 (alt)", "udp"]
|
||||
- ["209.224.0.4", "Level 3 2 (alt)", "udp"]
|
||||
- ["149.112.112.10", "Quad9 (UN 2)", "udp"]
|
||||
- ["77.88.8.88", "Yandex (Safe)", "udp"]
|
||||
- ["77.88.8.2", "Yandex (Safe 2)", "udp"]
|
||||
|
||||
# Нестабильные:
|
||||
# - ["https://doh.pub/dns-query", "DNSPod (doh.pub)", "doh_wire"]
|
||||
|
|
@ -177,6 +194,7 @@ DNS_AVAILABILITY_SERVERS:
|
|||
- ["https://xbox-dns.ru/dns-query", "XboxDNS (RU)", "doh_wire"]
|
||||
- ["https://common.dot.dns.yandex.net/dns-query", "Yandex", "doh_wire"]
|
||||
- ["https://dns.geohide.ru:444/dns-query", "Geohide", "doh_wire"]
|
||||
- ["https://safe.dot.dns.yandex.net/dns-query", "Yandex (Safe)", "doh_wire"]
|
||||
|
||||
|
||||
# Нестабильные:
|
||||
|
|
@ -216,6 +234,7 @@ DNS_AVAILABILITY_SERVERS:
|
|||
- ["common.dot.dns.yandex.net", "Yandex", "dot"]
|
||||
- ["dns.geohide.ru", "Geohide", "dot"]
|
||||
- ["dot.libredns.gr", "LibreDNS", "dot"]
|
||||
- ["safe.dot.dns.yandex.net", "Yandex (Safe)", "dot"]
|
||||
|
||||
# === Дополнительные настройки (меняются без правки кода) ===
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ rand = { workspace = true }
|
|||
thiserror = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
arabic_reshaper = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
url = "2.5"
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.55"
|
||||
|
|
|
|||
|
|
@ -68,9 +68,13 @@ fn d_dns_udp_servers() -> Vec<Vec<String>> {
|
|||
vec!["76.76.2.0".to_string(), "ControlD".to_string()],
|
||||
vec!["194.242.2.2".to_string(), "Mullvad".to_string()],
|
||||
vec!["185.228.168.9".to_string(), "CleanBrowsing".to_string()],
|
||||
vec!["76.76.19.19".to_string(), "Alternate DNS".to_string()],
|
||||
vec!["76.223.122.150".to_string(), "NextDNS".to_string()],
|
||||
vec!["185.222.222.222".to_string(), "DNS.SB".to_string()],
|
||||
vec!["176.9.93.198".to_string(), "dnsforge".to_string()],
|
||||
vec!["8.26.56.26".to_string(), "Comodo".to_string()],
|
||||
vec!["84.200.69.80".to_string(), "DNS Watch".to_string()],
|
||||
vec!["209.244.0.3".to_string(), "Level 3".to_string()],
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -962,14 +966,14 @@ mod tests {
|
|||
cfg.dns_check_domains,
|
||||
vec!["rutor.info", "flibusta.is", "clubtone.do.am", "rezka.ag", "shikimori.one"]
|
||||
);
|
||||
assert_eq!(cfg.dns_udp_servers.len(), 13);
|
||||
assert_eq!(cfg.dns_udp_servers.len(), 17);
|
||||
assert_eq!(cfg.dns_udp_servers[0], vec!["8.8.8.8", "Google"]);
|
||||
assert_eq!(cfg.dns_availability_domains, vec!["vk.ru", "gosuslugi.ru"]);
|
||||
let servers = cfg.availability_servers();
|
||||
assert_eq!(servers.iter().filter(|s| s.kind == "udp").count(), 37);
|
||||
assert_eq!(servers.iter().filter(|s| s.kind == "doh_wire").count(), 36);
|
||||
assert_eq!(servers.iter().filter(|s| s.kind == "dot").count(), 33);
|
||||
assert_eq!(servers.len(), 106);
|
||||
assert_eq!(servers.iter().filter(|s| s.kind == "udp").count(), 50);
|
||||
assert_eq!(servers.iter().filter(|s| s.kind == "doh_wire").count(), 37);
|
||||
assert_eq!(servers.iter().filter(|s| s.kind == "dot").count(), 34);
|
||||
assert_eq!(servers.len(), 121);
|
||||
assert_eq!(servers[0].addr, "94.140.14.14");
|
||||
assert_eq!(servers.last().map(|s| s.kind.as_str()), Some("dot"));
|
||||
assert_eq!(cfg.telegram_dc_list().len(), 5);
|
||||
|
|
@ -1016,6 +1020,6 @@ mod tests {
|
|||
assert!(!embedded_tcp16_targets().is_empty());
|
||||
assert!(!embedded_whitelist_sni().is_empty());
|
||||
let cfg = AppConfig::from_yaml_str(EMBEDDED_CONFIG_YML);
|
||||
assert_eq!(cfg.availability_servers().len(), 106);
|
||||
assert_eq!(cfg.availability_servers().len(), 121);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,44 @@ impl Language {
|
|||
}
|
||||
}
|
||||
|
||||
/// Shapes Arabic and Persian text into cursive presentation glyphs and reorders
|
||||
/// them according to the Unicode Bidirectional Algorithm for visual display in LTR terminals.
|
||||
/// For LTR languages (English, Russian, Chinese, Spanish), returns the text unchanged.
|
||||
pub fn format_bidi(text: &str, lang: Language) -> String {
|
||||
if !lang.is_rtl() {
|
||||
return text.to_string();
|
||||
}
|
||||
format_bidi_str(text)
|
||||
}
|
||||
|
||||
/// Shapes Arabic script characters into connected ligatures/presentation forms
|
||||
/// and reorders lines for terminal display.
|
||||
pub fn format_bidi_str(text: &str) -> String {
|
||||
// If text contains no Arabic/Persian characters, return early
|
||||
if !text.chars().any(|c| matches!(c, '\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}')) {
|
||||
return text.to_string();
|
||||
}
|
||||
|
||||
// 1. Reshape cursive Arabic / Persian characters into connected presentation forms
|
||||
let reshaped = arabic_reshaper::arabic_reshape(text);
|
||||
|
||||
// 2. Reorder for visual display using Unicode Bidirectional Algorithm per line/paragraph
|
||||
let bidi_info = unicode_bidi::BidiInfo::new(&reshaped, None);
|
||||
if bidi_info.paragraphs.is_empty() {
|
||||
return reshaped;
|
||||
}
|
||||
|
||||
let mut out = String::with_capacity(reshaped.len());
|
||||
for (i, para) in bidi_info.paragraphs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push('\n');
|
||||
}
|
||||
let line = bidi_info.reorder_line(para, para.range.clone());
|
||||
out.push_str(&line);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn detect_windows_language() -> Option<Language> {
|
||||
use winreg::enums::HKEY_CURRENT_USER;
|
||||
|
|
@ -1260,7 +1298,7 @@ pub fn get_messages(lang: Language) -> Messages {
|
|||
/// Terms stay Latin; descriptions follow the selected language (en/ru full,
|
||||
/// other languages fall back to English descriptions).
|
||||
pub fn print_legend(lang: Language, msg: &Messages) {
|
||||
println!("{}", msg.legend_title);
|
||||
println!("{}", format_bidi(msg.legend_title, lang));
|
||||
let sections = match lang {
|
||||
Language::Ru => legend_sections(),
|
||||
Language::Zh => legend_sections_zh(),
|
||||
|
|
@ -1270,9 +1308,9 @@ pub fn print_legend(lang: Language, msg: &Messages) {
|
|||
Language::En => legend_sections_en(),
|
||||
};
|
||||
for (section, items) in §ions {
|
||||
println!(" {}", section);
|
||||
println!(" {}", format_bidi(section, lang));
|
||||
for (term, desc) in items {
|
||||
println!(" \x1b[36m{:<14}\x1b[0m \x1b[2m{}\x1b[0m", term, desc);
|
||||
println!(" \x1b[36m{:<14}\x1b[0m \x1b[2m{}\x1b[0m", term, format_bidi(desc, lang));
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
|
@ -1630,4 +1668,28 @@ mod tests {
|
|||
assert_eq!(msg.menu_test_label('7'), "");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_bidi_farsi_and_arabic() {
|
||||
// LTR strings remain unchanged
|
||||
assert_eq!(format_bidi("English text", Language::En), "English text");
|
||||
assert_eq!(format_bidi("Русский текст", Language::Ru), "Русский текст");
|
||||
|
||||
// Farsi words are shaped and reordered
|
||||
let shaped = format_bidi("خروج", Language::Fa);
|
||||
assert_ne!(shaped, "خروج");
|
||||
// Contains Arabic Presentation Forms
|
||||
assert!(shaped.chars().any(|c| matches!(c, '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}')));
|
||||
|
||||
// Complex Farsi sentence
|
||||
let menu_title = "پارامترها و انتخاب آزمونها";
|
||||
let shaped_title = format_bidi(menu_title, Language::Fa);
|
||||
assert_ne!(shaped_title, menu_title);
|
||||
assert!(shaped_title.chars().any(|c| matches!(c, '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}')));
|
||||
|
||||
// Arabic string
|
||||
let ar_text = "المعلمات واختيار الاختبارات";
|
||||
let shaped_ar = format_bidi(ar_text, Language::Ar);
|
||||
assert_ne!(shaped_ar, ar_text);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex};
|
|||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||
use dpi_core::config::AppConfig;
|
||||
use dpi_core::i18n::{get_messages, Language, Messages};
|
||||
use dpi_core::i18n::{format_bidi, get_messages, Language, Messages};
|
||||
use dpi_core::net::netinfo::ipv6_supported;
|
||||
use dpi_core::net::version::{version_badge_lang, ReleaseInfo};
|
||||
use dpi_core::profile::RegionProfile;
|
||||
|
|
@ -241,10 +241,11 @@ fn draw_menu(
|
|||
let lang_opts = Language::ALL
|
||||
.iter()
|
||||
.map(|&l| {
|
||||
let lbl = format_bidi(l.label(), current_lang);
|
||||
if l == current_lang {
|
||||
format!("\x1b[1;32m●\x1b[0m {}", l.label())
|
||||
format!("\x1b[1;32m●\x1b[0m {}", lbl)
|
||||
} else {
|
||||
format!("\x1b[2m○ {}\x1b[0m", l.label())
|
||||
format!("\x1b[2m○ {}\x1b[0m", lbl)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
|
|
@ -268,10 +269,12 @@ fn draw_menu(
|
|||
Language::Es => "(no disponible)",
|
||||
Language::En => "(unavailable)",
|
||||
};
|
||||
format!("\x1b[1;32m●\x1b[0m IPv4 \x1b[2m○ IPv6 {}\x1b[0m", unavail)
|
||||
let unavail_bidi = format_bidi(unavail, current_lang);
|
||||
format!("\x1b[1;32m●\x1b[0m IPv4 \x1b[2m○ IPv6 {}\x1b[0m", unavail_bidi)
|
||||
};
|
||||
let ip_cursor = if cursor == 1 { "►" } else { " " };
|
||||
lines.push(format!(" {} {} {}", ip_cursor, pad_width(msg.menu_ip_version, 15), ip_opts));
|
||||
let ip_lbl = format_bidi(msg.menu_ip_version, current_lang);
|
||||
lines.push(format!(" {} {} {}", ip_cursor, pad_width(&ip_lbl, 15), ip_opts));
|
||||
|
||||
// Concurrency row
|
||||
let conc_opts = presets
|
||||
|
|
@ -286,8 +289,8 @@ fn draw_menu(
|
|||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let conc_cursor = if cursor == 2 { "►" } else { " " };
|
||||
lines.push(format!(" {} {} {}", conc_cursor, pad_width(msg.menu_concurrency, 15), conc_opts));
|
||||
|
||||
let conc_lbl = format_bidi(msg.menu_concurrency, current_lang);
|
||||
lines.push(format!(" {} {} {}", conc_cursor, pad_width(&conc_lbl, 15), conc_opts));
|
||||
lines.push(format!(" {}", "─".repeat(BOX_WIDTH - 8)));
|
||||
|
||||
for (i, (digit, label)) in test_options.iter().enumerate() {
|
||||
|
|
@ -298,12 +301,12 @@ fn draw_menu(
|
|||
"\x1b[2m[ ]\x1b[0m"
|
||||
};
|
||||
let row_cursor = if cursor == i + offset { "►" } else { " " };
|
||||
lines.push(format!(" {} {} {}. {}", row_cursor, check_box, digit, label));
|
||||
lines.push(format!(" {} {} {}. {}", row_cursor, check_box, digit, format_bidi(label, current_lang)));
|
||||
}
|
||||
// Glyph-safe content first: widths are measured after replacement.
|
||||
let lines: Vec<String> = lines.into_iter().map(|l| asc(&l)).collect();
|
||||
|
||||
let title_clean = format!(" {} ", asc(msg.menu_title));
|
||||
let title_clean = format!(" {} ", asc(&format_bidi(msg.menu_title, current_lang)));
|
||||
let title_len = strip_ansi_len(&title_clean);
|
||||
let border_total = BOX_WIDTH.saturating_sub(title_len + 3);
|
||||
let (tl, tr, bl, br, hb, vb) = if ascii_mode() {
|
||||
|
|
@ -322,9 +325,13 @@ fn draw_menu(
|
|||
|
||||
print!("\x1b[1;36m{}{}{}\x1b[0m\r\n", bl, hb.repeat(BOX_WIDTH - 2), br);
|
||||
print!("{}\r\n", asc(&format!(" \x1b[1;46;37m ↑↓ \x1b[0m {} │ \x1b[1;46;37m ←→ \x1b[0m {} │ \x1b[1;46;37m 0-6 \x1b[0m {} │ \x1b[1;42;37m Enter \x1b[0m {} │ \x1b[1;41;37m Q \x1b[0m {}",
|
||||
msg.menu_hw_row, msg.menu_hw_change, msg.menu_hw_tests, msg.menu_hw_start, msg.menu_hw_quit)));
|
||||
format_bidi(msg.menu_hw_row, current_lang),
|
||||
format_bidi(msg.menu_hw_change, current_lang),
|
||||
format_bidi(msg.menu_hw_tests, current_lang),
|
||||
format_bidi(msg.menu_hw_start, current_lang),
|
||||
format_bidi(msg.menu_hw_quit, current_lang))));
|
||||
if let Some(n) = notice {
|
||||
print!(" \x1b[1;33m{}\x1b[0m\r\n", n);
|
||||
print!(" \x1b[1;33m{}\x1b[0m\r\n", format_bidi(n, current_lang));
|
||||
}
|
||||
let _ = stdout().flush();
|
||||
}
|
||||
|
|
@ -385,7 +392,7 @@ mod tests {
|
|||
let test_options = get_test_options(&msg);
|
||||
|
||||
// Title
|
||||
let title_clean = format!(" {} ", asc(msg.menu_title));
|
||||
let title_clean = format!(" {} ", asc(&format_bidi(msg.menu_title, lang)));
|
||||
let title_len = strip_ansi_len(&title_clean);
|
||||
assert!(title_len + 3 <= BOX_WIDTH, "title for {:?} overflows box: {}", lang, title_len);
|
||||
|
||||
|
|
@ -393,31 +400,35 @@ mod tests {
|
|||
let lang_opts = Language::ALL
|
||||
.iter()
|
||||
.map(|&l| {
|
||||
let lbl = format_bidi(l.label(), lang);
|
||||
if l == lang {
|
||||
format!("\x1b[1;32m●\x1b[0m {}", l.label())
|
||||
format!("\x1b[1;32m●\x1b[0m {}", lbl)
|
||||
} else {
|
||||
format!("\x1b[2m○ {}\x1b[0m", l.label())
|
||||
format!("\x1b[2m○ {}\x1b[0m", lbl)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let lang_line = format!(" ► {} {}", pad_width(msg.menu_language, 8), lang_opts);
|
||||
let lang_lbl = format_bidi(msg.menu_language, lang);
|
||||
let lang_line = format!(" ► {} {}", pad_width(&lang_lbl, 8), lang_opts);
|
||||
let w = strip_ansi_len(&lang_line);
|
||||
assert!(w + 3 <= BOX_WIDTH, "language row for {:?} overflows box: w={}", lang, w);
|
||||
|
||||
// IP version row
|
||||
let ip_line = format!(" {} ● IPv4 ○ IPv6", pad_width(msg.menu_ip_version, 15));
|
||||
let ip_lbl = format_bidi(msg.menu_ip_version, lang);
|
||||
let ip_line = format!(" {} ● IPv4 ○ IPv6", pad_width(&ip_lbl, 15));
|
||||
let w = strip_ansi_len(&ip_line);
|
||||
assert!(w + 3 <= BOX_WIDTH, "ip row for {:?} overflows box: w={}", lang, w);
|
||||
|
||||
// Concurrency row
|
||||
let conc_line = format!(" {} ● 50 ○ 100", pad_width(msg.menu_concurrency, 15));
|
||||
let conc_lbl = format_bidi(msg.menu_concurrency, lang);
|
||||
let conc_line = format!(" {} ● 50 ○ 100", pad_width(&conc_lbl, 15));
|
||||
let w = strip_ansi_len(&conc_line);
|
||||
assert!(w + 3 <= BOX_WIDTH, "conc row for {:?} overflows box: w={}", lang, w);
|
||||
|
||||
// Test options
|
||||
for (digit, label) in test_options {
|
||||
let test_line = format!(" [ ] {}. {}", digit, label);
|
||||
let test_line = format!(" [ ] {}. {}", digit, format_bidi(label, lang));
|
||||
let w = strip_ansi_len(&test_line);
|
||||
assert!(w + 3 <= BOX_WIDTH, "test row {} for {:?} overflows box: w={}", digit, lang, w);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,8 +135,9 @@ pub fn panel_to_string(title: &str, lines: &[String]) -> String {
|
|||
/// Mirrors Rich: banner is left-titled cyan, netinfo centered dim.
|
||||
pub fn panel_with(title: &str, lines: &[String], width: usize, centered: bool, border: &str) -> String {
|
||||
// Glyph-safe content first: widths are measured after replacement.
|
||||
let title_clean = format!(" {} ", asc(title));
|
||||
let lines: Vec<String> = lines.iter().map(|l| asc(l)).collect();
|
||||
let title_bidi = dpi_core::i18n::format_bidi_str(title);
|
||||
let title_clean = format!(" {} ", asc(&title_bidi));
|
||||
let lines: Vec<String> = lines.iter().map(|l| asc(&dpi_core::i18n::format_bidi_str(l))).collect();
|
||||
let mut out = String::new();
|
||||
// Titles may carry SGR escapes (e.g. the bold banner title): measure visible
|
||||
// width only, and re-arm the border color after the title so an inner reset
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue