use crate::ported::colorscheme::{ATTR_BOLD, ATTR_ITALIC, ATTR_UNDERLINE};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ColorSpec {
pub cterm: u16,
pub truecolor: Option<u32>,
}
pub fn mode_translations() -> HashMap<String, String> {
let mut t = HashMap::new();
t.insert("\x16".to_string(), "^V".to_string());
t.insert("\x13".to_string(), "^S".to_string());
t
}
#[derive(Debug, Clone)]
pub struct HlGroup {
pub ctermfg: String,
pub guifg: Option<u32>,
pub ctermbg: String,
pub guibg: Option<u32>,
pub attrs: Vec<String>,
pub name: String,
}
impl HlGroup {
pub fn vim_command(&self) -> String {
let guifg = match self.guifg {
Some(rgb) => format!("#{:06x}", rgb),
None => "NONE".to_string(),
};
let guibg = match self.guibg {
Some(rgb) => format!("#{:06x}", rgb),
None => "NONE".to_string(),
};
let attrs = if self.attrs.is_empty() {
"NONE".to_string()
} else {
self.attrs.join(",")
};
format!(
"hi {} ctermfg={} guifg={} guibg={} ctermbg={} cterm={} gui={}",
self.name, self.ctermfg, guifg, guibg, self.ctermbg, attrs, attrs
)
}
}
pub struct VimRenderer {
pub hl_groups: HashMap<(Option<ColorSpec>, Option<ColorSpec>, u32), HlGroup>,
pub prev_highlight: Option<(Option<ColorSpec>, Option<ColorSpec>, u32)>,
pub theme: serde_json::Value,
pub local_themes: std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>>,
pub theme_config: serde_json::Value,
pub theme_kwargs: serde_json::Map<String, serde_json::Value>,
pub segment_info: serde_json::Map<String, serde_json::Value>,
pub shutdown_called: std::sync::Mutex<Vec<String>>,
}
impl Default for VimRenderer {
fn default() -> Self {
Self::new()
}
}
impl VimRenderer {
pub fn new() -> Self {
Self {
hl_groups: HashMap::new(),
prev_highlight: None,
theme: serde_json::Value::Null,
local_themes: std::collections::HashMap::new(),
theme_config: serde_json::Value::Null,
theme_kwargs: serde_json::Map::new(),
segment_info: serde_json::Map::new(),
shutdown_called: std::sync::Mutex::new(Vec::new()),
}
}
pub fn shutdown(&self) {
let mut log = self
.shutdown_called
.lock()
.unwrap_or_else(|e| e.into_inner());
log.push("theme".to_string());
for (name, match_entry) in &self.local_themes {
if match_entry.contains_key("theme") {
log.push(name.clone());
}
}
}
pub fn add_local_theme(
&mut self,
matcher: &str,
theme: serde_json::Map<String, serde_json::Value>,
) -> Result<(), String> {
if self.local_themes.contains_key(matcher) {
return Err("There is already a local theme with given matcher".to_string());
}
self.local_themes.insert(matcher.to_string(), theme);
Ok(())
}
pub fn get_matched_theme(&self, matcher: &str) -> serde_json::Value {
let match_entry = match self.local_themes.get(matcher) {
Some(e) => e,
None => return serde_json::Value::Null,
};
if let Some(t) = match_entry.get("theme") {
return t.clone();
}
serde_json::json!({
"theme_config": match_entry.get("config").cloned().unwrap_or(serde_json::Value::Null),
"main_theme_config": self.theme_config.clone(),
"theme_kwargs": serde_json::Value::Object(self.theme_kwargs.clone()),
})
}
pub fn get_matched_theme_cached(&mut self, matcher: &str) -> serde_json::Value {
let constructed = self.get_matched_theme(matcher);
if let Some(entry) = self.local_themes.get_mut(matcher) {
if !entry.contains_key("theme") {
entry.insert("theme".to_string(), constructed.clone());
}
}
constructed
}
pub fn get_theme<F>(
&mut self,
matcher_info: Option<&serde_json::Map<String, serde_json::Value>>,
matchers: &[(&str, F)],
) -> serde_json::Value
where
F: Fn(&serde_json::Map<String, serde_json::Value>) -> bool,
{
let info = match matcher_info {
Some(i) => i,
None => {
return self.get_matched_theme_cached("");
}
};
for (key, matcher_fn) in matchers {
if !key.is_empty() && matcher_fn(info) && self.local_themes.contains_key(*key) {
return self.get_matched_theme_cached(key);
}
}
self.theme.clone()
}
pub fn get_segment_info(
&self,
segment_info: Option<&serde_json::Map<String, serde_json::Value>>,
_mode: &str,
) -> serde_json::Map<String, serde_json::Value> {
match segment_info {
Some(s) if !s.is_empty() => s.clone(),
_ => self.segment_info.clone(),
}
}
pub fn character_translations() -> Vec<(char, &'static str)> {
vec![('%', "%%")]
}
pub fn strwidth(string: &str) -> usize {
string.chars().count()
}
pub fn render(
renderer_segment_info: &serde_json::Map<String, serde_json::Value>,
window: Option<i64>,
window_id: Option<u64>,
winnr: Option<i64>,
is_tabline: bool,
is_current_window: bool,
current_mode_translation: Option<&str>,
) -> serde_json::Map<String, serde_json::Value> {
let mut segment_info = renderer_segment_info.clone();
let mode = if is_current_window {
current_mode_translation.unwrap_or("nc").to_string()
} else {
"nc".to_string()
};
if let Some(w) = window {
segment_info.insert("window".to_string(), serde_json::Value::Number(w.into()));
}
if let Some(wid) = window_id {
segment_info.insert(
"window_id".to_string(),
serde_json::Value::Number(wid.into()),
);
}
if let Some(n) = winnr {
segment_info.insert("winnr".to_string(), serde_json::Value::Number(n.into()));
}
segment_info.insert("mode".to_string(), serde_json::Value::String(mode));
segment_info.insert(
"is_tabline".to_string(),
serde_json::Value::Bool(is_tabline),
);
segment_info
}
pub fn reset_highlight(&mut self) {
self.hl_groups.clear();
}
fn build_group_name(g: &HlGroup) -> String {
let guifg = match g.guifg {
Some(rgb) => format!("{}", rgb),
None => "None".to_string(),
};
let guibg = match g.guibg {
Some(rgb) => format!("{}", rgb),
None => "None".to_string(),
};
format!(
"Pl_{}_{}_{}_{}_{}",
g.ctermfg,
guifg,
g.ctermbg,
guibg,
g.attrs.join("")
)
}
pub fn attrs_to_hi_attrs(attrs: u32) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
if attrs & ATTR_BOLD != 0 {
out.push("bold".to_string());
}
if attrs & ATTR_ITALIC != 0 {
out.push("italic".to_string());
}
if attrs & ATTR_UNDERLINE != 0 {
out.push("underline".to_string());
}
out
}
pub fn hlstyle(
&mut self,
fg: Option<ColorSpec>,
bg: Option<ColorSpec>,
attrs: Option<u32>,
commands: &mut Vec<String>,
) -> String {
let attrs = attrs.unwrap_or(0);
if Some((fg, bg, attrs)) == self.prev_highlight {
return String::new();
}
self.prev_highlight = Some((fg, bg, attrs));
if attrs == 0 && bg.is_none() && fg.is_none() {
return String::new();
}
let key = (fg, bg, attrs);
#[allow(clippy::map_entry)]
if !self.hl_groups.contains_key(&key) {
let mut g = HlGroup {
ctermfg: "NONE".to_string(),
guifg: None,
ctermbg: "NONE".to_string(),
guibg: None,
attrs: vec!["NONE".to_string()],
name: String::new(),
};
if let Some(f) = fg {
g.ctermfg = f.cterm.to_string();
g.guifg = f.truecolor;
}
if let Some(b) = bg {
g.ctermbg = b.cterm.to_string();
g.guibg = b.truecolor;
}
if attrs != 0 {
g.attrs = Self::attrs_to_hi_attrs(attrs);
}
g.name = Self::build_group_name(&g);
commands.push(g.vim_command());
self.hl_groups.insert(key, g);
}
format!("%#{}#", self.hl_groups[&key].name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_translations_contains_ctrl_v_and_ctrl_s() {
let t = mode_translations();
assert_eq!(t.get("\x16").map(|s| s.as_str()), Some("^V"));
assert_eq!(t.get("\x13").map(|s| s.as_str()), Some("^S"));
}
#[test]
fn character_translations_doubles_percent() {
let t = VimRenderer::character_translations();
assert_eq!(t, vec![('%', "%%")]);
}
#[test]
fn attrs_to_hi_attrs_zero_is_empty() {
assert!(VimRenderer::attrs_to_hi_attrs(0).is_empty());
}
#[test]
fn attrs_to_hi_attrs_bold() {
assert_eq!(VimRenderer::attrs_to_hi_attrs(ATTR_BOLD), vec!["bold"]);
}
#[test]
fn attrs_to_hi_attrs_all_three() {
let r = VimRenderer::attrs_to_hi_attrs(ATTR_BOLD | ATTR_ITALIC | ATTR_UNDERLINE);
assert_eq!(r, vec!["bold", "italic", "underline"]);
}
#[test]
fn reset_highlight_clears_cache() {
let mut r = VimRenderer::new();
let mut commands = Vec::new();
r.hlstyle(
Some(ColorSpec {
cterm: 231,
truecolor: None,
}),
None,
Some(0),
&mut commands,
);
assert!(!r.hl_groups.is_empty());
r.reset_highlight();
assert!(r.hl_groups.is_empty());
}
#[test]
fn hlstyle_no_args_returns_empty() {
let mut r = VimRenderer::new();
let mut commands = Vec::new();
assert_eq!(r.hlstyle(None, None, None, &mut commands), "");
assert!(commands.is_empty());
}
#[test]
fn hlstyle_emits_group_reference() {
let mut r = VimRenderer::new();
let mut commands = Vec::new();
let s = r.hlstyle(
Some(ColorSpec {
cterm: 231,
truecolor: Some(0xffffff),
}),
None,
None,
&mut commands,
);
assert!(s.starts_with("%#Pl_231_"));
assert!(s.ends_with("#"));
assert_eq!(commands.len(), 1);
assert!(commands[0].starts_with("hi Pl_231_"));
assert!(commands[0].contains("guifg=#ffffff"));
}
#[test]
fn hlstyle_consecutive_identical_returns_empty() {
let mut r = VimRenderer::new();
let mut commands = Vec::new();
let fg = Some(ColorSpec {
cterm: 21,
truecolor: None,
});
let first = r.hlstyle(fg, None, None, &mut commands);
assert!(!first.is_empty());
let second = r.hlstyle(fg, None, None, &mut commands);
assert_eq!(second, "");
assert_eq!(commands.len(), 1);
}
#[test]
fn hlstyle_caches_repeated_group_after_other_style() {
let mut r = VimRenderer::new();
let mut commands = Vec::new();
let fg_a = Some(ColorSpec {
cterm: 21,
truecolor: None,
});
let fg_b = Some(ColorSpec {
cterm: 22,
truecolor: None,
});
let _ = r.hlstyle(fg_a, None, None, &mut commands);
let _ = r.hlstyle(fg_b, None, None, &mut commands);
let s = r.hlstyle(fg_a, None, None, &mut commands);
assert!(s.starts_with("%#Pl_21_"));
assert_eq!(commands.len(), 2); }
#[test]
fn hl_group_vim_command_format() {
let g = HlGroup {
ctermfg: "231".to_string(),
guifg: Some(0xffffff),
ctermbg: "21".to_string(),
guibg: Some(0x0000ff),
attrs: vec!["bold".to_string()],
name: "Pl_231_16777215_21_255_bold".to_string(),
};
let cmd = g.vim_command();
assert!(cmd.starts_with("hi Pl_231_16777215_21_255_bold "));
assert!(cmd.contains("ctermfg=231"));
assert!(cmd.contains("guifg=#ffffff"));
assert!(cmd.contains("ctermbg=21"));
assert!(cmd.contains("guibg=#0000ff"));
assert!(cmd.contains("cterm=bold"));
assert!(cmd.contains("gui=bold"));
}
#[test]
fn hl_group_vim_command_none_attrs_emits_none() {
let g = HlGroup {
ctermfg: "NONE".to_string(),
guifg: None,
ctermbg: "NONE".to_string(),
guibg: None,
attrs: vec!["NONE".to_string()],
name: "Pl_NONE_None_NONE_None_NONE".to_string(),
};
let cmd = g.vim_command();
assert!(cmd.contains("guifg=NONE"));
assert!(cmd.contains("guibg=NONE"));
assert!(cmd.contains("cterm=NONE"));
}
#[test]
fn hlstyle_attrs_only_emits_cterm_attrs() {
let mut r = VimRenderer::new();
let mut commands = Vec::new();
let s = r.hlstyle(None, None, Some(ATTR_BOLD | ATTR_UNDERLINE), &mut commands);
assert!(s.starts_with("%#Pl_NONE_None_NONE_None_boldunderline#"));
assert!(commands[0].contains("cterm=bold,underline"));
}
#[test]
fn shutdown_records_main_theme_first() {
let r = VimRenderer::new();
r.shutdown();
let log = r.shutdown_called.lock().unwrap();
assert_eq!(log[0], "theme");
}
#[test]
fn shutdown_walks_local_themes_with_theme_key() {
let mut r = VimRenderer::new();
let mut with_theme = serde_json::Map::new();
with_theme.insert("theme".to_string(), serde_json::json!({}));
let no_theme: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
r.local_themes.insert("ready".to_string(), with_theme);
r.local_themes.insert("not_ready".to_string(), no_theme);
r.shutdown();
let log = r.shutdown_called.lock().unwrap();
assert!(log.contains(&"ready".to_string()));
assert!(!log.contains(&"not_ready".to_string()));
}
#[test]
fn add_local_theme_inserts_into_dict() {
let mut r = VimRenderer::new();
let mut theme = serde_json::Map::new();
theme.insert("config".to_string(), serde_json::json!({"key": "value"}));
let result = r.add_local_theme("help", theme);
assert!(result.is_ok());
assert!(r.local_themes.contains_key("help"));
}
#[test]
fn add_local_theme_rejects_duplicate_matcher() {
let mut r = VimRenderer::new();
let theme1 = serde_json::Map::new();
let theme2 = serde_json::Map::new();
assert!(r.add_local_theme("help", theme1).is_ok());
let err = r.add_local_theme("help", theme2).unwrap_err();
assert!(err.contains("already a local theme"));
}
#[test]
fn get_matched_theme_returns_existing_theme() {
let mut r = VimRenderer::new();
let mut entry = serde_json::Map::new();
entry.insert("theme".to_string(), serde_json::json!({"name": "help"}));
r.local_themes.insert("help".to_string(), entry);
let t = r.get_matched_theme("help");
assert_eq!(t["name"], "help");
}
#[test]
fn get_matched_theme_constructs_from_config_when_missing() {
let mut r = VimRenderer::new();
r.theme_config = serde_json::json!({"colorscheme": "default"});
r.theme_kwargs
.insert("extra".to_string(), serde_json::json!("kw_value"));
let mut entry = serde_json::Map::new();
entry.insert("config".to_string(), serde_json::json!({"segments": []}));
r.local_themes.insert("help".to_string(), entry);
let t = r.get_matched_theme("help");
assert_eq!(t["theme_config"]["segments"], serde_json::json!([]));
assert_eq!(t["main_theme_config"]["colorscheme"], "default");
assert_eq!(t["theme_kwargs"]["extra"], "kw_value");
}
#[test]
fn get_matched_theme_cached_back_inserts_theme_key() {
let mut r = VimRenderer::new();
let mut entry = serde_json::Map::new();
entry.insert("config".to_string(), serde_json::json!({"a": 1}));
r.local_themes.insert("help".to_string(), entry);
let _ = r.get_matched_theme_cached("help");
let cached = r
.local_themes
.get("help")
.and_then(|m| m.get("theme"))
.cloned();
assert!(cached.is_some());
}
#[test]
fn get_theme_none_matcher_uses_empty_key_local_theme() {
let mut r = VimRenderer::new();
let mut entry = serde_json::Map::new();
entry.insert("theme".to_string(), serde_json::json!({"name": "default"}));
r.local_themes.insert("".to_string(), entry);
let matchers: &[(
&str,
fn(&serde_json::Map<String, serde_json::Value>) -> bool,
)] = &[];
let t = r.get_theme(None, matchers);
assert_eq!(t["name"], "default");
}
#[test]
fn get_theme_walks_matchers_and_returns_first_match() {
let mut r = VimRenderer::new();
let mut entry = serde_json::Map::new();
entry.insert(
"theme".to_string(),
serde_json::json!({"name": "help-theme"}),
);
r.local_themes.insert("help".to_string(), entry);
let mut info = serde_json::Map::new();
info.insert("filetype".to_string(), serde_json::json!("help"));
let matchers: Vec<(
&str,
fn(&serde_json::Map<String, serde_json::Value>) -> bool,
)> = vec![("help", |i| {
i.get("filetype").and_then(|v| v.as_str()) == Some("help")
})];
let t = r.get_theme(Some(&info), &matchers);
assert_eq!(t["name"], "help-theme");
}
#[test]
fn get_theme_no_matcher_match_falls_back_to_self_theme() {
let mut r = VimRenderer::new();
r.theme = serde_json::json!({"name": "default"});
let info = serde_json::Map::new();
let matchers: Vec<(
&str,
fn(&serde_json::Map<String, serde_json::Value>) -> bool,
)> = vec![("never", |_| false)];
let t = r.get_theme(Some(&info), &matchers);
assert_eq!(t["name"], "default");
}
#[test]
fn get_segment_info_returns_supplied_when_present() {
let r = VimRenderer::new();
let mut info = serde_json::Map::new();
info.insert("buffer".to_string(), serde_json::json!("foo"));
let result = r.get_segment_info(Some(&info), "n");
assert_eq!(result["buffer"], "foo");
}
#[test]
fn get_segment_info_falls_back_to_self_when_none() {
let mut r = VimRenderer::new();
r.segment_info
.insert("default".to_string(), serde_json::json!("yes"));
let result = r.get_segment_info(None, "n");
assert_eq!(result["default"], "yes");
}
#[test]
fn get_segment_info_falls_back_to_self_when_supplied_empty() {
let mut r = VimRenderer::new();
r.segment_info
.insert("default".to_string(), serde_json::json!("yes"));
let empty = serde_json::Map::new();
let result = r.get_segment_info(Some(&empty), "n");
assert_eq!(result["default"], "yes");
}
#[test]
fn strwidth_counts_ascii_chars() {
assert_eq!(VimRenderer::strwidth("hello"), 5);
assert_eq!(VimRenderer::strwidth(""), 0);
}
#[test]
fn render_returns_nc_mode_when_not_current_window() {
let base = serde_json::Map::new();
let result =
VimRenderer::render(&base, Some(1), Some(42), Some(2), false, false, Some("n"));
assert_eq!(
result.get("mode"),
Some(&serde_json::Value::String("nc".to_string()))
);
}
#[test]
fn render_uses_supplied_mode_for_current_window() {
let base = serde_json::Map::new();
let result =
VimRenderer::render(&base, Some(1), Some(42), Some(2), false, true, Some("INS"));
assert_eq!(
result.get("mode"),
Some(&serde_json::Value::String("INS".to_string()))
);
}
#[test]
fn render_injects_window_id_and_winnr_keys() {
let base = serde_json::Map::new();
let result = VimRenderer::render(&base, Some(7), Some(13), Some(2), true, false, None);
assert_eq!(result.get("window_id").and_then(|v| v.as_u64()), Some(13));
assert_eq!(result.get("winnr").and_then(|v| v.as_i64()), Some(2));
assert_eq!(
result.get("is_tabline"),
Some(&serde_json::Value::Bool(true))
);
}
}