mod roles;
pub(crate) use roles::ThemeRoles;
use crate::config::{AppearanceSettings, ColorChoice, McPaths, Settings};
use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt, OpenOptionsSyncExt};
use cap_std::{
ambient_authority,
fs::{Dir as CapDir, DirEntry as CapDirEntry, File as CapFile, OpenOptions as CapOpenOptions},
};
use opaline::{
Gradient, OpalineColor, OpalineStyle, Theme, ThemeFile,
builtins::{builtin_names, load_by_name},
names::{gradients, styles, tokens},
};
use std::{
collections::{BTreeMap, HashSet},
env,
ffi::OsStr,
io::{ErrorKind, Read},
path::Path,
sync::Arc,
};
pub(crate) const DEFAULT_THEME_ID: &str = "matrix-green";
const FAST_GRADIENT_NAME: &str = "magi.gradient.fast";
const MAX_CUSTOM_THEME_BYTES: usize = 512 * 1024;
const MAX_CUSTOM_THEME_FILES: usize = 128;
const MAX_CUSTOM_THEME_DIRECTORY_ENTRIES: usize = 256;
const MAX_CUSTOM_THEME_COLORS: usize = 4096;
const MAX_CUSTOM_THEME_STYLES: usize = 1024;
const MAX_CUSTOM_THEME_GRADIENTS: usize = 256;
const MAX_CUSTOM_THEME_GRADIENT_STOPS: usize = 4096;
const MAX_CUSTOM_THEME_TOKEN_REFERENCE_DEPTH: usize = 64;
const MAX_THEME_DIAGNOSTICS: usize = 24;
const MATRIX_GREEN_TOML: &str = include_str!("appearance/matrix-green.toml");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ColorDepth {
TrueColor,
Ansi256,
Ansi16,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ColorPolicy {
pub(crate) depth: ColorDepth,
pub(crate) color_enabled: bool,
pub(crate) unicode_enabled: bool,
pub(crate) rich_output: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct RuntimeAppearance {
pub(crate) theme: Arc<ResolvedTheme>,
pub(crate) policy: ColorPolicy,
pub(crate) reduced_motion: bool,
}
impl Default for RuntimeAppearance {
fn default() -> Self {
let theme = load_matrix_theme();
Self {
theme: Arc::new(ResolvedTheme::from_theme(DEFAULT_THEME_ID, &theme)),
policy: ColorPolicy {
depth: ColorDepth::TrueColor,
color_enabled: true,
unicode_enabled: true,
rich_output: true,
},
reduced_motion: false,
}
}
}
impl RuntimeAppearance {
pub(crate) fn with_resolved_theme(&self, theme: ResolvedTheme) -> Self {
Self {
theme: Arc::new(theme),
policy: self.policy,
reduced_motion: self.reduced_motion,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct AppearanceResolution {
pub(crate) appearance: RuntimeAppearance,
pub(crate) diagnostics: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct ThemeCatalog {
entries: BTreeMap<String, CatalogTheme>,
diagnostics: Vec<String>,
}
#[derive(Debug, Clone)]
struct CatalogTheme {
theme: Theme,
display_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ThemeCatalogEntry {
pub(crate) id: String,
pub(crate) display_name: String,
pub(crate) variant: opaline::ThemeVariant,
}
impl ThemeCatalog {
pub(crate) fn load(mc_home: &Path) -> Self {
let mut catalog = Self {
entries: BTreeMap::new(),
diagnostics: Vec::new(),
};
for &(id, _) in builtin_names() {
let theme = load_builtin_theme(id).expect("registered Opaline builtin must load");
catalog.insert_builtin(id, theme);
}
catalog.insert_builtin(DEFAULT_THEME_ID, load_matrix_theme());
catalog.scan_custom(mc_home);
catalog
}
pub(crate) fn resolve(&self, id: &str) -> Option<ResolvedTheme> {
self.entries
.get(id)
.map(|entry| ResolvedTheme::from_theme(id, &entry.theme))
}
pub(crate) fn entries(&self) -> Vec<ThemeCatalogEntry> {
self.entries
.iter()
.map(|(id, entry)| ThemeCatalogEntry {
id: id.clone(),
display_name: entry.display_name.clone(),
variant: entry.theme.meta.variant,
})
.collect()
}
pub(crate) fn resolve_runtime_appearance(
&self,
id: &str,
base: &RuntimeAppearance,
) -> Option<RuntimeAppearance> {
self.resolve(id)
.map(|theme| base.with_resolved_theme(theme))
}
pub(crate) fn diagnostics(&self) -> &[String] {
&self.diagnostics
}
fn insert_builtin(&mut self, id: &str, theme: Theme) {
self.entries.insert(
id.to_string(),
CatalogTheme {
display_name: sanitize_name(&theme.meta.name),
theme,
},
);
}
fn scan_custom(&mut self, mc_home: &Path) {
let themes_dir = match open_themes_directory(mc_home) {
Ok(Some(directory)) => directory,
Ok(None) => return,
Err(reason) => {
self.warn(&format!("custom themes directory skipped: {reason}"));
return;
}
};
let Ok(mut directory) = themes_dir.entries() else {
self.warn("custom themes directory could not be read");
return;
};
let mut entries = Vec::with_capacity(MAX_CUSTOM_THEME_DIRECTORY_ENTRIES);
for entry_index in 0..=MAX_CUSTOM_THEME_DIRECTORY_ENTRIES {
let Some(entry_result) = directory.next() else {
break;
};
if entry_index == MAX_CUSTOM_THEME_DIRECTORY_ENTRIES {
self.warn(
"custom theme directory entry limit reached; remaining entries were skipped",
);
break;
}
match entry_result {
Ok(entry) => entries.push(entry),
Err(_) => self.warn("a custom theme directory entry could not be inspected"),
}
}
entries.sort_by_key(|entry| entry.file_name());
let mut accepted_count = 0;
for entry in entries {
let file_name = entry.file_name();
let Some(file_name) = file_name.to_str() else {
self.warn("a custom theme had a non-UTF-8 filename");
continue;
};
if !valid_custom_filename(file_name) {
continue;
}
if accepted_count >= MAX_CUSTOM_THEME_FILES {
self.warn("custom theme file limit reached; remaining files were skipped");
break;
}
let id = format!("custom:{file_name}");
let theme = match read_custom_theme_from_entry(&entry, Some(Path::new(file_name))) {
Ok(theme) => theme,
Err(error) => {
self.warn_for_file(file_name, error.reason());
continue;
}
};
if self.entries.contains_key(&id) {
self.warn_for_file(file_name, "theme ID conflicts with an existing theme");
continue;
}
accepted_count += 1;
self.entries.insert(
id,
CatalogTheme {
display_name: sanitize_name(&theme.meta.name),
theme,
},
);
}
}
fn warn_for_file(&mut self, file_name: &str, reason: &str) {
self.warn(&format!(
"custom theme '{}' skipped: {reason}",
sanitize_name(file_name)
));
}
fn warn(&mut self, message: &str) {
if self.diagnostics.len() < MAX_THEME_DIAGNOSTICS {
self.diagnostics.push(sanitize_diagnostic(message));
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ThemeLoadFailure {
Missing,
InvalidId,
Link,
NonRegular,
TooLarge,
Unreadable,
InvalidUtf8,
Malformed,
SafetyLimit,
Incomplete,
DirectoryUnsafe,
}
impl ThemeLoadFailure {
fn reason(self) -> &'static str {
match self {
Self::Missing => "theme file is missing",
Self::InvalidId => "theme ID is not a direct .toml filename",
Self::Link => "links are not allowed",
Self::NonRegular => "only regular files are allowed",
Self::TooLarge => "file is too large",
Self::Unreadable => "file could not be read",
Self::InvalidUtf8 => "file contents are not UTF-8",
Self::Malformed => "theme is malformed or uses unsupported fields",
Self::SafetyLimit => "theme exceeds safety limits",
Self::Incomplete => "theme is missing required standard entries",
Self::DirectoryUnsafe => "themes directory is not a safe directory",
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("invalid theme '{id}': {reason}")]
pub(crate) struct InvalidExplicitTheme {
id: String,
reason: &'static str,
}
fn load_builtin_theme(id: &str) -> Option<Theme> {
let theme = load_by_name(id)?;
assert!(
has_standard_theme_contract(&theme),
"Opaline builtin {id} is missing the standard theme contract"
);
Some(theme)
}
fn load_matrix_theme() -> Theme {
parse_theme_source(MATRIX_GREEN_TOML, None)
.expect("embedded Matrix Green theme must remain valid")
}
fn parse_theme_source(source: &str, path: Option<&Path>) -> Result<Theme, ThemeLoadFailure> {
if source.len() > MAX_CUSTOM_THEME_BYTES {
return Err(ThemeLoadFailure::TooLarge);
}
let theme_file: ThemeFile = toml::from_str(source).map_err(|_| ThemeLoadFailure::Malformed)?;
validate_theme_file_bounds(&theme_file)?;
let theme = opaline::load_from_str(source, path).map_err(|_| ThemeLoadFailure::Malformed)?;
if !has_standard_theme_contract(&theme) {
return Err(ThemeLoadFailure::Incomplete);
}
Ok(theme)
}
fn has_standard_theme_contract(theme: &Theme) -> bool {
let token_names = theme.token_names();
STANDARD_TOKENS
.iter()
.all(|name| token_names.contains(name))
&& STANDARD_STYLES
.iter()
.all(|name| theme.try_style(name).is_some())
&& STANDARD_GRADIENTS
.iter()
.all(|name| theme.get_gradient(name).is_some())
}
const STANDARD_TOKENS: [&str; 26] = [
tokens::TEXT_PRIMARY,
tokens::TEXT_SECONDARY,
tokens::TEXT_MUTED,
tokens::TEXT_DIM,
tokens::BG_BASE,
tokens::BG_PANEL,
tokens::BG_CODE,
tokens::BG_HIGHLIGHT,
tokens::BG_SELECTION,
tokens::ACCENT_PRIMARY,
tokens::ACCENT_SECONDARY,
tokens::ACCENT_TERTIARY,
tokens::ACCENT_DEEP,
tokens::SUCCESS,
tokens::ERROR,
tokens::WARNING,
tokens::INFO,
tokens::BORDER_FOCUSED,
tokens::BORDER_UNFOCUSED,
tokens::CODE_KEYWORD,
tokens::CODE_FUNCTION,
tokens::CODE_STRING,
tokens::CODE_NUMBER,
tokens::CODE_COMMENT,
tokens::CODE_TYPE,
tokens::CODE_LINE_NUMBER,
];
const STANDARD_STYLES: [&str; 13] = [
styles::KEYWORD,
styles::LINE_NUMBER,
styles::SELECTED,
styles::ACTIVE_SELECTED,
styles::FOCUSED_BORDER,
styles::UNFOCUSED_BORDER,
styles::SUCCESS_STYLE,
styles::ERROR_STYLE,
styles::WARNING_STYLE,
styles::INFO_STYLE,
styles::DIMMED,
styles::MUTED,
styles::INLINE_CODE,
];
const STANDARD_GRADIENTS: [&str; 5] = [
gradients::PRIMARY,
gradients::WARM,
gradients::SUCCESS_GRADIENT,
gradients::ERROR_GRADIENT,
gradients::AURORA,
];
fn validate_theme_file_bounds(theme_file: &ThemeFile) -> Result<(), ThemeLoadFailure> {
if theme_file
.palette
.len()
.saturating_add(theme_file.tokens.len())
> MAX_CUSTOM_THEME_COLORS
|| theme_file.styles.len() > MAX_CUSTOM_THEME_STYLES
|| theme_file.gradients.len() > MAX_CUSTOM_THEME_GRADIENTS
{
return Err(ThemeLoadFailure::SafetyLimit);
}
let stop_count = theme_file
.gradients
.values()
.fold(0usize, |count, stops| count.saturating_add(stops.len()));
if stop_count > MAX_CUSTOM_THEME_GRADIENT_STOPS {
return Err(ThemeLoadFailure::SafetyLimit);
}
if !token_reference_depth_is_bounded(theme_file) {
return Err(ThemeLoadFailure::SafetyLimit);
}
Ok(())
}
fn token_reference_depth_is_bounded(theme_file: &ThemeFile) -> bool {
for name in theme_file.tokens.keys() {
let mut current = name.as_str();
let mut seen = HashSet::new();
let mut depth = 0;
loop {
if depth >= MAX_CUSTOM_THEME_TOKEN_REFERENCE_DEPTH || !seen.insert(current) {
return false;
}
depth += 1;
let Some(reference) = theme_file.tokens.get(current) else {
break;
};
if reference.starts_with('#') || theme_file.palette.contains_key(reference) {
break;
}
if !theme_file.tokens.contains_key(reference) {
break;
}
current = reference;
}
}
true
}
fn open_themes_directory(mc_home: &Path) -> Result<Option<CapDir>, &'static str> {
let home = match CapDir::open_ambient_dir(mc_home, ambient_authority()) {
Ok(home) => home,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(_) => return Err("MC_HOME could not be inspected"),
};
match home.open_dir_nofollow("themes") {
Ok(themes) => Ok(Some(themes)),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(_) => Err("themes directory is not a safe directory"),
}
}
fn read_custom_theme_from_entry(
entry: &CapDirEntry,
path: Option<&Path>,
) -> Result<Theme, ThemeLoadFailure> {
let file = open_theme_file_entry(entry)?;
read_custom_theme_file(file, path)
}
fn read_custom_theme(directory: &CapDir, file_name: &str) -> Result<Theme, ThemeLoadFailure> {
if !valid_custom_filename(file_name) {
return Err(ThemeLoadFailure::InvalidId);
}
let entry = find_custom_theme_entry(directory, file_name)?;
read_custom_theme_from_entry(&entry, Some(Path::new(file_name)))
}
fn find_custom_theme_entry(
directory: &CapDir,
file_name: &str,
) -> Result<CapDirEntry, ThemeLoadFailure> {
let mut entries = directory
.entries()
.map_err(|_| ThemeLoadFailure::Unreadable)?;
for _ in 0..MAX_CUSTOM_THEME_DIRECTORY_ENTRIES {
let Some(entry_result) = entries.next() else {
return Err(ThemeLoadFailure::Missing);
};
let Ok(entry) = entry_result else {
continue;
};
if entry.file_name().to_str() == Some(file_name) {
return Ok(entry);
}
}
Err(ThemeLoadFailure::Missing)
}
fn read_custom_theme_file(file: CapFile, path: Option<&Path>) -> Result<Theme, ThemeLoadFailure> {
let metadata = file.metadata().map_err(|_| ThemeLoadFailure::Unreadable)?;
if metadata.len() > MAX_CUSTOM_THEME_BYTES as u64 {
return Err(ThemeLoadFailure::TooLarge);
}
let mut bytes = Vec::with_capacity(metadata.len().min(MAX_CUSTOM_THEME_BYTES as u64) as usize);
file.take((MAX_CUSTOM_THEME_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|_| ThemeLoadFailure::Unreadable)?;
if bytes.len() > MAX_CUSTOM_THEME_BYTES {
return Err(ThemeLoadFailure::TooLarge);
}
let source = std::str::from_utf8(&bytes).map_err(|_| ThemeLoadFailure::InvalidUtf8)?;
parse_theme_source(source, path)
}
fn theme_file_open_options() -> CapOpenOptions {
let mut options = CapOpenOptions::new();
options.read(true).follow(FollowSymlinks::No).nonblock(true);
options
}
fn open_theme_file_entry(entry: &CapDirEntry) -> Result<CapFile, ThemeLoadFailure> {
let file_type = entry
.file_type()
.map_err(|_| ThemeLoadFailure::Unreadable)?;
if file_type.is_symlink() {
return Err(ThemeLoadFailure::Link);
}
if !file_type.is_file() {
return Err(ThemeLoadFailure::NonRegular);
}
let file = entry
.open_with(&theme_file_open_options())
.map_err(|error| {
if error.kind() == ErrorKind::NotFound {
ThemeLoadFailure::Missing
} else {
ThemeLoadFailure::Unreadable
}
})?;
validate_opened_theme_file(file)
}
fn validate_opened_theme_file(file: CapFile) -> Result<CapFile, ThemeLoadFailure> {
let metadata = file.metadata().map_err(|_| ThemeLoadFailure::Unreadable)?;
if metadata.file_type().is_symlink() {
return Err(ThemeLoadFailure::Link);
}
if !metadata.is_file() {
return Err(ThemeLoadFailure::NonRegular);
}
Ok(file)
}
fn load_selected_theme(mc_home: &Path, id: &str) -> Result<Option<Theme>, ThemeLoadFailure> {
if id == DEFAULT_THEME_ID {
return Ok(Some(load_matrix_theme()));
}
if builtin_names()
.iter()
.any(|(builtin_id, _)| *builtin_id == id)
{
return load_builtin_theme(id)
.map(Some)
.ok_or(ThemeLoadFailure::Malformed);
}
let Some(file_name) = id.strip_prefix("custom:") else {
return Ok(None);
};
if !valid_custom_filename(file_name) {
return Err(ThemeLoadFailure::InvalidId);
}
let themes_dir = open_themes_directory(mc_home)
.map_err(|_| ThemeLoadFailure::DirectoryUnsafe)?
.ok_or(ThemeLoadFailure::Missing)?;
read_custom_theme(&themes_dir, file_name).map(Some)
}
fn valid_custom_filename(file_name: &str) -> bool {
!file_name.is_empty()
&& file_name.ends_with(".toml")
&& !file_name.contains('\0')
&& Path::new(file_name)
.file_name()
.is_some_and(|component| component == OsStr::new(file_name))
}
#[derive(Debug, Clone)]
pub(crate) struct ResolvedTheme {
pub(crate) id: String,
pub(crate) roles: ThemeRoles,
}
impl ResolvedTheme {
fn from_theme(id: &str, theme: &Theme) -> Self {
Self {
id: id.to_string(),
roles: ThemeRoles::from_theme(theme),
}
}
}
pub(crate) fn resolve_startup(
paths: &McPaths,
settings: &Settings,
appearance: &AppearanceSettings,
cli_theme: Option<&str>,
cli_color: Option<ColorChoice>,
stdout_is_tty: bool,
) -> anyhow::Result<AppearanceResolution> {
let selected_id = cli_theme.unwrap_or(appearance.theme.as_str());
let mut diagnostics = Vec::new();
let resolved = match load_selected_theme(&paths.root, selected_id) {
Ok(Some(theme)) => ResolvedTheme::from_theme(selected_id, &theme),
Ok(None) if cli_theme.is_some() => {
return Err(anyhow::Error::new(InvalidExplicitTheme {
id: sanitize_name(selected_id),
reason: "theme ID is not a known built-in or custom theme",
}));
}
Err(error) if cli_theme.is_some() => {
return Err(anyhow::Error::new(InvalidExplicitTheme {
id: sanitize_name(selected_id),
reason: error.reason(),
}));
}
Ok(None) | Err(_) => {
diagnostics.push(format!(
"saved theme '{}' is unavailable; using Matrix Green",
sanitize_name(selected_id)
));
let matrix = load_matrix_theme();
ResolvedTheme::from_theme(DEFAULT_THEME_ID, &matrix)
}
};
let policy = resolve_color_policy_from_env(
settings.no_color,
cli_color,
stdout_is_tty,
env::var_os("NO_COLOR").is_some(),
env::var("COLORTERM").ok().as_deref(),
env::var("TERM").ok().as_deref(),
);
Ok(AppearanceResolution {
appearance: RuntimeAppearance {
theme: Arc::new(resolved),
policy,
reduced_motion: appearance.reduced_motion,
},
diagnostics: bounded_diagnostics(diagnostics),
})
}
pub(crate) fn detect_color_depth(colorterm: Option<&str>, term: Option<&str>) -> ColorDepth {
if colorterm.is_some_and(|value| {
value.eq_ignore_ascii_case("truecolor") || value.eq_ignore_ascii_case("24bit")
}) {
ColorDepth::TrueColor
} else if term.is_some_and(|value| value.contains("256color")) {
ColorDepth::Ansi256
} else {
ColorDepth::Ansi16
}
}
pub(crate) fn resolve_color_policy_from_env(
saved_no_color: Option<bool>,
cli_color: Option<ColorChoice>,
stdout_is_tty: bool,
no_color_present: bool,
colorterm: Option<&str>,
term: Option<&str>,
) -> ColorPolicy {
let color_enabled = match cli_color {
Some(ColorChoice::Always) => true,
Some(ColorChoice::Never) => false,
Some(ColorChoice::Auto) => stdout_is_tty,
None => {
stdout_is_tty
&& !saved_no_color.unwrap_or(false)
&& !no_color_present
&& !term.is_some_and(|value| value.eq_ignore_ascii_case("dumb"))
}
};
let depth = if color_enabled {
detect_color_depth(colorterm, term)
} else {
ColorDepth::None
};
ColorPolicy {
depth,
color_enabled,
unicode_enabled: stdout_is_tty,
rich_output: stdout_is_tty || color_enabled,
}
}
pub(crate) fn ansi256_index(color: OpalineColor) -> u8 {
let cube_levels = [0u8, 95, 135, 175, 215, 255];
let nearest_level = |value: u8| {
cube_levels
.iter()
.enumerate()
.min_by_key(|(_, level)| u8::abs_diff(value, **level))
.map(|(index, _)| index as u8)
.unwrap_or_default()
};
let cube_index =
16 + 36 * nearest_level(color.r) + 6 * nearest_level(color.g) + nearest_level(color.b);
let cube_color = ansi256_rgb(cube_index);
let cube_distance = color_distance(color, cube_color.0, cube_color.1, cube_color.2);
if color.r == color.g && color.g == color.b {
let gray_step = ((u16::from(color.r) + 2) / 10).saturating_sub(1).min(23) as u8;
let gray_index = 232 + gray_step;
let gray_color = ansi256_rgb(gray_index);
if color_distance(color, gray_color.0, gray_color.1, gray_color.2) < cube_distance {
return gray_index;
}
}
cube_index
}
pub(crate) fn ansi16_index(color: OpalineColor) -> u8 {
let mut best_index = 0;
let mut best_distance = u32::MAX;
for (index, &(r, g, b)) in ANSI16_PALETTE.iter().enumerate() {
let distance = color_distance(color, r, g, b);
if distance < best_distance {
best_distance = distance;
best_index = index as u8;
}
}
best_index
}
const ANSI16_PALETTE: [(u8, u8, u8); 16] = [
(0, 0, 0),
(128, 0, 0),
(0, 128, 0),
(128, 128, 0),
(0, 0, 128),
(128, 0, 128),
(0, 128, 128),
(192, 192, 192),
(128, 128, 128),
(255, 0, 0),
(0, 255, 0),
(255, 255, 0),
(0, 0, 255),
(255, 0, 255),
(0, 255, 255),
(255, 255, 255),
];
fn ansi256_rgb(index: u8) -> (u8, u8, u8) {
match index {
0..=15 => ANSI16_PALETTE[index as usize],
16..=231 => {
let index = index - 16;
let level = [0, 95, 135, 175, 215, 255];
(
level[(index / 36) as usize],
level[((index % 36) / 6) as usize],
level[(index % 6) as usize],
)
}
232..=255 => {
let gray = 8 + (index - 232) * 10;
(gray, gray, gray)
}
}
}
fn color_distance(color: OpalineColor, r: u8, g: u8, b: u8) -> u32 {
let dr = i32::from(color.r) - i32::from(r);
let dg = i32::from(color.g) - i32::from(g);
let db = i32::from(color.b) - i32::from(b);
(dr * dr + dg * dg + db * db) as u32
}
fn bounded_diagnostics(mut diagnostics: Vec<String>) -> Vec<String> {
diagnostics.truncate(MAX_THEME_DIAGNOSTICS);
diagnostics
}
fn sanitize_name(name: &str) -> String {
truncate_chars(name.chars().filter(|ch| !ch.is_control()).collect(), 96)
}
fn sanitize_diagnostic(message: &str) -> String {
truncate_chars(message.chars().filter(|ch| !ch.is_control()).collect(), 240)
}
fn truncate_chars(mut value: String, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value;
}
let end = value
.char_indices()
.nth(max_chars)
.map(|(index, _)| index)
.unwrap_or(value.len());
value.truncate(end);
value.push('…');
value
}