use std::collections::{HashMap, HashSet};
use std::ops::Index;
use std::sync::LazyLock;
#[cfg(not(test))]
use std::sync::RwLock;
use ratatui::style::{Color, Modifier, Style};
use serde::Deserialize;
use these_macros_should_be_illegal::enum_fn;
use super::palette::Palette;
#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct StyleDeclaration {
required: bool,
captures: Option<Vec<String>>,
link: Option<String>,
fg: Option<String>,
bg: Option<String>,
sp: Option<String>,
fg_from: Option<String>,
bg_from: Option<String>,
sp_from: Option<String>,
bold: Option<bool>,
italic: Option<bool>,
underline: Option<bool>,
reverse: Option<bool>,
crossed_out: Option<bool>,
dim: Option<bool>,
saturation: Option<f32>,
lightening: Option<f32>,
darken: Option<f32>,
bg_blend: Option<String>,
bg_blend_amount: Option<f32>,
tint_from: Option<String>,
tint: Option<f32>,
}
pub type StyleTable = HashMap<String, StyleDeclaration>;
pub struct GroupDeclarations(StyleTable);
const DEFAULTS: &str = include_str!("groups.toml");
impl GroupDeclarations {
fn embedded() -> Self {
Self(toml::from_str(DEFAULTS).expect("embedded group declarations must parse"))
}
pub fn parse(source: &str) -> Result<Self, String> {
let custom: StyleTable = toml::from_str(source).map_err(|e| e.to_string())?;
let assigned: HashSet<String> = custom
.values()
.flat_map(|declaration| declaration.captures.iter().flatten().cloned())
.collect();
let mut declarations = Self::embedded();
for captures in declarations.0.values_mut().flat_map(|d| &mut d.captures) {
captures.retain(|capture| !assigned.contains(capture));
}
for (name, mut declaration) in custom {
let original = declarations
.0
.get(&name)
.ok_or_else(|| format!("unknown highlight group `{name}`"))?;
if original.required && !declaration.required {
return Err(format!(
"required group `{name}` cannot become a default link"
));
}
if !original.required && declaration.required {
return Err(format!("linked group `{name}` cannot become required"));
}
declaration.required = original.required;
if declaration.captures.is_none() {
declaration.captures.clone_from(&original.captures);
}
declarations.0.insert(name, declaration);
}
declarations.captures()?;
Ok(declarations)
}
pub fn draws_captures(&self, group: HLGroup) -> bool {
self.0
.get(group.description())
.is_some_and(|declaration| declaration.captures.iter().flatten().next().is_some())
}
pub fn captures(&self) -> Result<Vec<(String, HLGroup)>, String> {
let mut owners = HashMap::new();
for (name, declaration) in &self.0 {
let group = HLGroup::named(name)?;
for capture in declaration.captures.iter().flatten() {
if let Some(owner) = owners.insert(capture.clone(), group) {
return Err(format!(
"capture `@{capture}` belongs to both `{}` and `{name}`",
owner.description()
));
}
}
}
Ok(owners.into_iter().collect())
}
fn load() -> Self {
match super::config_source("groups.toml", DEFAULTS).and_then(|s| Self::parse(&s)) {
Ok(declarations) => {
for (name, source) in super::SCHEME_FILES {
let file: super::SchemeFile =
toml::from_str(source).expect("embedded scheme must parse");
if let Err(error) = file.resolve_with(&declarations) {
eprintln!(
"highlight groups for {name}: {error}; using built-in declarations"
);
return Self::embedded();
}
}
declarations
}
Err(error) => {
eprintln!("highlight groups: {error}; using built-in declarations");
Self::embedded()
}
}
}
}
pub static GROUP_DECLARATIONS: LazyLock<GroupDeclarations> = LazyLock::new(GroupDeclarations::load);
macro_rules! highlight_groups {
($($group:ident),+ $(,)?) => {
#[enum_fn(description: &'static str = stringify)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum HLGroup {$(
#[doc = concat!("The `", stringify!($group), "` highlight group.")]
$group,
)+}
impl HLGroup {
pub fn named(name: &str) -> Result<Self, String> {
match name {
$(stringify!($group) => Ok(Self::$group),)+
_ => Err(format!("unknown highlight group `{name}`")),
}
}
}
#[allow(non_snake_case)]
#[derive(Clone, Copy)]
pub struct Groups {$(
#[doc = concat!("Resolved style for `", stringify!($group), "`.")]
pub $group: Style,
)+}
impl Groups {
pub fn resolve(
styles: &StyleTable,
overrides: &StyleTable,
palette: &Palette,
declarations: &GroupDeclarations,
) -> Result<Self, String> {
for (name, declaration) in styles.iter().chain(overrides) {
HLGroup::named(name)?;
if declaration.required || declaration.captures.is_some() {
return Err(format!(
"{name}: required and captures belong only in groups.toml"
));
}
}
for (name, declaration) in &declarations.0 {
if declaration.required && !styles.contains_key(name) {
return Err(format!("missing required highlight group `{name}`"));
}
}
let mut resolver = StyleResolver {
declarations, styles, overrides, palette,
resolved: HashMap::new(), visiting: Vec::new(),
};
Ok(Self {$($group: resolver.group(stringify!($group))?,)+})
}
pub fn highlight_groups(&self) -> Vec<(&'static str, Style)> {
vec![$((HLGroup::$group.description(), self.$group),)+]
}
}
impl Groups {
pub fn styled_groups(&self) -> Vec<(HLGroup, Style)> {
vec![$((HLGroup::$group, self.$group),)+]
}
}
impl Index<HLGroup> for Groups {
type Output = Style;
fn index(&self, group: HLGroup) -> &Self::Output {
match group {$(HLGroup::$group => &self.$group,)+}
}
}
};
}
include!(concat!(env!("OUT_DIR"), "/highlight_groups.rs"));
struct StyleResolver<'a> {
declarations: &'a GroupDeclarations,
styles: &'a StyleTable,
overrides: &'a StyleTable,
palette: &'a Palette,
resolved: HashMap<String, Style>,
visiting: Vec<String>,
}
impl StyleResolver<'_> {
fn channel(&mut self, reference: &str) -> Result<Option<Color>, String> {
let (name, channel) = reference
.rsplit_once('.')
.ok_or_else(|| format!("expected Group.fg, Group.bg or Group.sp: `{reference}`"))?;
let style = self.group(name)?;
match channel {
"fg" => Ok(style.fg),
"bg" => Ok(style.bg),
"sp" => Ok(style.underline_color),
_ => Err(format!("unknown style channel `{reference}`")),
}
}
fn group(&mut self, name: &str) -> Result<Style, String> {
if let Some(style) = self.resolved.get(name) {
return Ok(*style);
}
HLGroup::named(name)?;
if self.visiting.iter().any(|entry| entry == name) {
return Err(format!(
"cyclic highlight links: {} -> {name}",
self.visiting.join(" -> ")
));
}
let declaration = self
.overrides
.get(name)
.or_else(|| self.styles.get(name))
.or_else(|| self.declarations.0.get(name))
.cloned()
.ok_or_else(|| format!("missing highlight declaration `{name}`"))?;
self.visiting.push(name.into());
let style = self
.style(&declaration)
.map_err(|error| format!("{name}: {error}"))?;
self.visiting.pop();
self.resolved.insert(name.into(), style);
Ok(style)
}
fn style(&mut self, declaration: &StyleDeclaration) -> Result<Style, String> {
let mut style = match &declaration.link {
Some(link) => self.group(link)?,
None => Style::new(),
};
for (direct, reference, channel) in [
(&declaration.fg, &declaration.fg_from, "fg"),
(&declaration.bg, &declaration.bg_from, "bg"),
(&declaration.sp, &declaration.sp_from, "sp"),
] {
if direct.is_some() && reference.is_some() {
return Err(format!("{channel} and {channel}_from cannot both be set"));
}
let color = match reference {
Some(reference) => Some(self.channel(reference)?),
None => direct
.as_deref()
.map(|name| {
if name == "none" {
Ok(None)
} else {
self.palette.resolve(Some(name))
}
})
.transpose()?,
};
if let Some(color) = color {
match channel {
"fg" => style.fg = color,
"bg" => style.bg = color,
_ => style.underline_color = color,
}
}
}
for (value, modifier) in [
(declaration.bold, Modifier::BOLD),
(declaration.italic, Modifier::ITALIC),
(declaration.underline, Modifier::UNDERLINED),
(declaration.reverse, Modifier::REVERSED),
(declaration.crossed_out, Modifier::CROSSED_OUT),
(declaration.dim, Modifier::DIM),
] {
if let Some(value) = value {
style = if value {
style.add_modifier(modifier)
} else {
style.remove_modifier(modifier)
};
}
}
if declaration.saturation.is_some() || declaration.lightening.is_some() {
let saturation = declaration.saturation.unwrap_or(100.0);
let lightening = declaration.lightening.unwrap_or(0.0);
for amount in [saturation, lightening] {
if !amount.is_finite() || !(0.0..=100.0).contains(&amount) {
return Err("saturation and lightening must be between 0 and 100".into());
}
}
style = softened(style, saturation, lightening);
}
if let Some(amount) = declaration.darken {
if !amount.is_finite() || !(0.0..=1.0).contains(&amount) {
return Err("darken must be between 0 and 1".into());
}
if let Some(fg) = style.fg {
style.fg = Some(super::palette::blend(fg, Color::Rgb(0, 0, 0), amount)?);
}
}
match (&declaration.bg_blend, declaration.bg_blend_amount) {
(Some(color), Some(amount)) => {
let bg = style.bg.ok_or("bg_blend needs a background")?;
let color = self
.palette
.resolve(Some(color))?
.expect("a named palette color exists");
style.bg = Some(super::palette::blend(bg, color, amount)?);
}
(None, None) => {}
_ => return Err("bg_blend and bg_blend_amount must be set together".into()),
}
match (&declaration.tint_from, declaration.tint) {
(Some(reference), Some(amount)) => {
if !amount.is_finite() || !(0.0..=1.0).contains(&amount) {
return Err("tint must be between 0 and 1".into());
}
if let (Some(bg), Some(fg)) = (style.bg, self.channel(reference)?) {
style.bg = Some(super::palette::blend(bg, fg, amount)?);
}
}
(None, None) => {}
_ => return Err("tint_from and tint must be set together".into()),
}
Ok(style)
}
}
fn softened(style: Style, saturation: f32, lightening: f32) -> Style {
let Some(Color::Rgb(r, g, b)) = style.fg else {
return style;
};
let [r, g, b] = [r, g, b].map(|channel| f32::from(channel) / 255.0);
let (high, low) = (r.max(g).max(b), r.min(g).min(b));
let chroma = high - low;
let value = high + (1.0 - high) * lightening / 100.0;
let kept = if high == 0.0 { 0.0 } else { chroma / high } * saturation / 100.0;
let floor = value * (1.0 - kept);
let channel = |from: f32| {
let along = if chroma == 0.0 {
0.0
} else {
(from - low) / chroma
};
let byte = (floor + (value - floor) * along) * 255.0;
byte.round().clamp(0.0, 255.0) as u8
};
style.fg(Color::Rgb(channel(r), channel(g), channel(b)))
}
#[cfg(not(test))]
static SCHEME: LazyLock<RwLock<&'static Groups>> =
LazyLock::new(|| RwLock::new(Box::leak(Box::new(Groups::default()))));
#[cfg(not(test))]
pub fn scheme() -> &'static Groups {
*SCHEME
.read()
.expect("the scheme lock is never held across a panic")
}
#[cfg(not(test))]
pub fn publish(groups: Groups) {
*SCHEME
.write()
.expect("the scheme lock is never held across a panic") = Box::leak(Box::new(groups));
}
#[cfg(test)]
thread_local! {
static SCHEME: std::cell::Cell<&'static Groups> =
std::cell::Cell::new(Box::leak(Box::new(Groups::default())));
}
#[cfg(test)]
pub fn scheme() -> &'static Groups {
SCHEME.get()
}
#[cfg(test)]
pub fn publish(groups: Groups) {
SCHEME.set(Box::leak(Box::new(groups)));
}
impl Groups {
pub fn style(&self, group: HLGroup) -> Style {
self[group]
}
pub fn diagnostic(&self) -> Style {
self.DiagnosticError
}
}
macro_rules! accessors {
($($name:ident: $group:ident),+ $(,)?) => {
impl Groups {$(
pub fn $name(&self) -> Style {
self.$group
}
)+}
};
}
accessors! {
transcript: Canvas,
code: Code,
pending: Pending,
separator: Separator,
picker: Picker,
picker_selected: PickerSelected,
picker_selected_label: PickerSelectedLabel,
value: Value,
error: Error,
warning: Warning,
info: Info,
preview_mode: Documentation,
tree_code: Symbol,
type_info: Type,
border: ValueBorder,
output_number: OutputNumber,
recent_output: RecentOutput,
recent_cell_number: RecentCellNumber,
prompt: InputPrompt,
command: Command,
selection: Selection,
muted: Muted,
printed: Printed,
completion_hint: CompletionHint,
placeholder_hint: PlaceholderHint,
picker_marker: PickerMarker,
picker_selected_marker: PickerSelectedMarker,
picker_title: PickerTitle,
picker_border: PickerBorder,
picker_tab: PickerTab,
picker_tab_selected: PickerTabSelected,
cell_number: CellNumber,
pending_cell_marker: PendingCellMarker,
queued_cell_marker: QueuedCellMarker,
failed_cell_marker: FailedCellMarker,
rejected_cell_marker: RejectedCellMarker,
printed_cell_number: PrintedCellNumber,
pending_separator: PendingSeparator,
muted_code_text: MutedCodeText,
settings_item_label: SettingsItemLabel,
settings_item_value: SettingsItemValue,
settings_item_value_selected: SettingsItemValueSelected,
settings_item_unavailable: SettingsItemUnavailable,
settings_item_disabled: SettingsItemDisabled,
settings_unavailable_note: SettingsUnavailableNote,
settings_item_description: SettingsItemDescription,
settings_step_arrow: SettingsStepArrow,
settings_button: SettingsButton,
keymap_action_label: KeymapActionLabel,
command_action_description: CommandActionDescription,
command_shortcut: CommandShortcut,
frame_stats: FrameStats,
highlight_inspector_title: HighlightInspectorTitle,
highlight_inspector_border: HighlightInspectorBorder,
view_divider: ViewDivider,
syntax_tree_branch: SyntaxTreeBranch,
syntax_tree_token_marker: SyntaxTreeTokenMarker,
syntax_tree_label_separator: SyntaxTreeLabelSeparator,
syntax_tree_border: SyntaxTreeBorder,
error_border: ErrorBorder,
error_message: ErrorMessage,
error_title: ErrorTitle,
error_code: ErrorCode,
warning_border: WarningBorder,
info_border: InfoBorder,
statusline_bg: StatuslineBg,
}
impl Default for Groups {
fn default() -> Self {
super::Appearance::default().groups()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> (StyleTable, Palette) {
let file: super::super::SchemeFile =
toml::from_str(super::super::SCHEME_FILES[0].1).unwrap();
let palette = file
.palette
.palette(super::super::Mode::Dark, "mocha")
.unwrap();
(file.groups, palette)
}
#[test]
fn explicit_styles_replace_links_and_can_remove_inherited_modifiers() {
let (styles, palette) = fixture();
let overrides = toml::from_str(
r#"
Picker = { bold = true }
RecentOutput = { link = "Accent", bold = false }
"#,
)
.unwrap();
let groups = Groups::resolve(
&styles,
&overrides,
&palette,
&GroupDeclarations::embedded(),
)
.unwrap();
assert_eq!(groups.Picker, Style::new().bold());
assert!(!groups.RecentOutput.add_modifier.contains(Modifier::BOLD));
assert!(groups.RecentOutput.sub_modifier.contains(Modifier::BOLD));
}
#[test]
fn a_capture_assigned_by_the_user_leaves_its_default_group() {
let (capture, _) = GroupDeclarations::embedded().captures().unwrap().remove(0);
let moved =
GroupDeclarations::parse(&format!("Separator = {{ captures = ['{capture}'] }}"))
.unwrap();
assert!(moved
.captures()
.unwrap()
.contains(&(capture.clone(), HLGroup::Separator)));
assert!(GroupDeclarations::parse(&format!(
"Separator = {{ captures = ['{capture}'] }}\nPending = {{ captures = ['{capture}'] }}"
))
.err()
.unwrap()
.contains(&capture));
}
#[test]
fn cycles_missing_required_styles_and_bad_references_are_rejected() {
let (mut styles, palette) = fixture();
let overrides: StyleTable = toml::from_str(
r#"
CompletionHint = { link = "PlaceholderHint" }
PlaceholderHint = { fg_from = "CompletionHint.fg" }
"#,
)
.unwrap();
let error = Groups::resolve(
&styles,
&overrides,
&palette,
&GroupDeclarations::embedded(),
)
.err()
.unwrap();
assert!(error.contains("cyclic"), "{error}");
for source in [
"CompletionHint = { link = 'NoSuchGroup' }",
"CompletionHint = { fg_from = 'Text.typo' }",
"CompletionHint = { fg = 'invented_color' }",
"CompletionHint = { bg = 'base', bg_blend = 'red', bg_blend_amount = 1.5 }",
] {
let overrides = toml::from_str(source).unwrap();
assert!(
Groups::resolve(
&styles,
&overrides,
&palette,
&GroupDeclarations::embedded()
)
.is_err(),
"{source}"
);
}
styles.remove("Text");
assert!(Groups::resolve(
&styles,
&HashMap::new(),
&palette,
&GroupDeclarations::embedded()
)
.err()
.unwrap()
.contains("missing required highlight group `Text`"));
}
#[test]
fn groups_have_one_uniform_indexed_representation() {
let groups = Groups::default();
assert_eq!(groups.style(HLGroup::Number), groups[HLGroup::Number]);
assert_eq!(groups.style(HLGroup::Operator), groups[HLGroup::Operator]);
}
}