use crate::{
backend::color::ansi_to_rgb,
error::Error,
utils::{get_screen_size, get_window_size, is_mobile},
};
use compact_str::{format_compact, CompactString};
use ratatui::{
buffer::Cell,
layout::Size,
style::{Color, Modifier},
};
use unicode_width::UnicodeWidthStr;
use web_sys::{
wasm_bindgen::{JsCast, JsValue},
window, Document, Element, HtmlCanvasElement, Window,
};
pub struct CssAttribute {
pub field: &'static str,
pub value: Option<&'static str>,
}
pub(crate) fn create_span(document: &Document, cell: &Cell) -> Result<Element, Error> {
let span = document.create_element("span")?;
span.set_inner_html(cell.symbol());
let style = get_cell_style_as_css(cell);
span.set_attribute("style", &style)?;
Ok(span)
}
#[allow(dead_code)]
pub(crate) fn create_anchor(document: &Document, cells: &[Cell]) -> Result<Element, Error> {
let anchor = document.create_element("a")?;
anchor.set_attribute(
"href",
&cells.iter().map(|c| c.symbol()).collect::<String>(),
)?;
anchor.set_attribute("style", &get_cell_style_as_css(&cells[0]))?;
Ok(anchor)
}
pub(crate) fn get_cell_style_as_css(cell: &Cell) -> String {
let mut fg = ansi_to_rgb(cell.fg);
let mut bg = ansi_to_rgb(cell.bg);
if cell.modifier.contains(Modifier::REVERSED) {
std::mem::swap(&mut fg, &mut bg);
}
let fg_style = match fg {
Some(color) => format!("color: rgb({}, {}, {});", color.0, color.1, color.2),
None => "color: rgb(255, 255, 255);".to_string(),
};
let bg_style = match bg {
Some(color) => format!(
"background-color: rgb({}, {}, {});",
color.0, color.1, color.2
),
None => {
if cell.modifier.contains(Modifier::REVERSED) {
"background-color: rgb(255, 255, 255);".to_string()
} else {
"background-color: transparent;".to_string()
}
}
};
let mut modifier_style = String::new();
if cell.modifier.contains(Modifier::BOLD) {
modifier_style.push_str("font-weight: bold; ");
}
if cell.modifier.contains(Modifier::DIM) {
modifier_style.push_str("opacity: 0.5; ");
}
if cell.modifier.contains(Modifier::ITALIC) {
modifier_style.push_str("font-style: italic; ");
}
if cell.modifier.contains(Modifier::UNDERLINED) {
modifier_style.push_str("text-decoration: underline; ");
}
if cell.modifier.contains(Modifier::HIDDEN) {
modifier_style.push_str("visibility: hidden; ");
}
if cell.modifier.contains(Modifier::CROSSED_OUT) {
modifier_style.push_str("text-decoration: line-through; ");
}
let braille_style = if contains_braille(cell) {
"font-variant-numeric: tabular-nums; "
} else {
""
};
let sizing = format!("display: inline-block; width: {}ch;", cell.symbol().width());
format!("{fg_style} {bg_style} {modifier_style} {braille_style} {sizing}")
}
fn parse_inline_style(css: &str) -> Vec<(String, String)> {
css.split(';')
.filter_map(|decl| {
let decl = decl.trim();
if decl.is_empty() {
return None;
}
let mut parts = decl.splitn(2, ':');
let key = parts.next()?.trim();
let val = parts.next()?.trim();
if key.is_empty() || val.is_empty() {
None
} else {
Some((key.to_string(), val.to_string()))
}
})
.collect()
}
fn build_inline_style(styles: &[(String, String)]) -> String {
let mut s = String::new();
for (k, v) in styles {
s.push_str(format!("{k}: {v};").as_str());
}
s
}
fn set_or_remove_style_attribute(elem: &Element, css: String) -> Result<(), JsValue> {
if css.is_empty() {
elem.remove_attribute("style")
} else {
elem.set_attribute("style", &css)
}
}
pub(crate) fn update_css_field(attribute: CssAttribute, elem: &Element) -> Result<(), JsValue> {
let field = attribute.field;
let value = attribute.value;
let css = elem.get_attribute("style").unwrap_or_default();
let mut styles = parse_inline_style(&css);
let target = field.trim().to_string();
match value {
Some(new_val) => {
let new_val = new_val.trim().to_string();
let mut found = false;
for (k, v) in styles.iter_mut() {
if k.eq_ignore_ascii_case(&target) {
*v = new_val.clone();
found = true;
break;
}
}
if !found {
styles.push((target, new_val));
}
}
None => {
styles.retain(|(k, _)| !k.eq_ignore_ascii_case(&target));
}
}
let updated_css = build_inline_style(&styles);
set_or_remove_style_attribute(elem, updated_css)
}
pub(crate) fn get_canvas_color(color: Color, fallback_color: Color) -> CompactString {
let color = ansi_to_rgb(color).unwrap_or_else(|| ansi_to_rgb(fallback_color).unwrap());
format_compact!("rgb({}, {}, {})", color.0, color.1, color.2)
}
pub(crate) fn get_raw_window_size() -> (u16, u16) {
fn js_val_to_int<I: TryFrom<usize>>(val: JsValue) -> Option<I> {
val.as_f64().and_then(|i| I::try_from(i as usize).ok())
}
web_sys::window()
.and_then(|s| {
s.inner_width()
.ok()
.and_then(js_val_to_int::<u16>)
.zip(s.inner_height().ok().and_then(js_val_to_int::<u16>))
})
.unwrap_or((120, 120))
}
pub(crate) fn get_raw_screen_size() -> (i32, i32) {
let s = web_sys::window().unwrap().screen().unwrap();
(s.width().unwrap(), s.height().unwrap())
}
pub(crate) fn get_sized_buffer() -> Vec<Vec<Cell>> {
let size = get_size();
vec![vec![Cell::default(); size.width as usize]; size.height as usize]
}
pub(crate) fn get_size() -> Size {
if is_mobile() {
get_screen_size()
} else {
get_window_size()
}
}
pub(crate) fn get_sized_buffer_from_canvas(canvas: &HtmlCanvasElement) -> Vec<Vec<Cell>> {
let width = canvas.client_width() as u16 / 10_u16;
let height = canvas.client_height() as u16 / 19_u16;
vec![vec![Cell::default(); width as usize]; height as usize]
}
pub(crate) fn get_document() -> Result<Document, Error> {
get_window()?
.document()
.ok_or(Error::UnableToRetrieveDocument)
}
pub(crate) fn get_window() -> Result<Window, Error> {
window().ok_or(Error::UnableToRetrieveWindow)
}
pub(crate) fn get_element_by_id_or_body(id: Option<&String>) -> Result<web_sys::Element, Error> {
match id {
Some(id) => get_document()?
.get_element_by_id(id)
.ok_or_else(|| Error::UnableToRetrieveElementById(id.to_string())),
None => get_document()?
.body()
.ok_or(Error::UnableToRetrieveBody)
.map(|body| body.into()),
}
}
pub(crate) fn performance() -> Result<web_sys::Performance, Error> {
Ok(get_window()?
.performance()
.ok_or(Error::UnableToRetrieveComponent("Performance"))?)
}
pub(crate) fn create_canvas_in_element(
parent: &Element,
width: u32,
height: u32,
) -> Result<HtmlCanvasElement, Error> {
let element = get_document()?.create_element("canvas")?;
let canvas = element
.clone()
.dyn_into::<HtmlCanvasElement>()
.map_err(|_| ())
.expect("Unable to cast canvas element");
canvas.set_width(width);
canvas.set_height(height);
parent.append_child(&element)?;
Ok(canvas)
}
fn contains_braille(cell: &Cell) -> bool {
cell.symbol()
.chars()
.next()
.is_some_and(|c| ('\u{2800}'..='\u{28FF}').contains(&c))
}
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::*;
use web_sys::window;
wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
fn create_elem_with_style(s: &str) -> Element {
let doc = window().unwrap().document().unwrap();
let el = doc.create_element("div").unwrap();
if !s.is_empty() {
el.set_attribute("style", s).unwrap();
}
el
}
#[wasm_bindgen_test]
fn test_add_new_field() {
let el = create_elem_with_style("color: red;");
let attr = CssAttribute {
field: "background-color",
value: Some("blue"),
};
update_css_field(attr, &el).unwrap();
let got = el.get_attribute("style").unwrap();
assert!(got.contains("color: red;"));
assert!(got.contains("background-color: blue;"));
}
#[wasm_bindgen_test]
fn test_update_existing_field() {
let el = create_elem_with_style("color: red;");
let attr = CssAttribute {
field: "color",
value: Some("green"),
};
update_css_field(attr, &el).unwrap();
assert_eq!(el.get_attribute("style").unwrap(), "color: green;");
}
#[wasm_bindgen_test]
fn test_remove_field() {
let el = create_elem_with_style("color: red; background-color: blue;");
let attr = CssAttribute {
field: "color",
value: None,
};
update_css_field(attr, &el).unwrap();
let got = el.get_attribute("style").unwrap();
assert_eq!(got, "background-color: blue;");
}
#[wasm_bindgen_test]
fn test_remove_last_field_removes_attribute() {
let el = create_elem_with_style("color: red;");
let attr = CssAttribute {
field: "color",
value: None,
};
update_css_field(attr, &el).unwrap();
assert!(el.get_attribute("style").is_none());
}
}