use rdom_core::{DocumentPosition, Dom, NodeId, NodeType, Range};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::ext::TuiExt;
use crate::layout::LayoutRect;
use crate::node::TuiNodeExt;
use crate::render::inline::{InlineFragment, inline_flow_container};
use crate::render::{Buffer, Rect, Style};
use crate::style::{ComputedStyle, Modifier};
use super::layout_rect_to_grid;
use super::text::{glyph_style_from_computed, paint_text, style_from_computed};
pub(super) fn paint_inline_content(
dom: &Dom<TuiExt>,
id: NodeId,
computed: &ComputedStyle,
inner: LayoutRect,
buf: &mut Buffer,
clip: Rect,
) {
if dom
.node(id)
.ext()
.is_some_and(|e| !e.anonymous_blocks.is_empty())
{
return;
}
let avail_single_row = inner.width;
let chrome = if let Some((bar, color)) =
crate::runtime::builtins::gauge::gauge_text(dom, id, avail_single_row)
{
Some((
bar,
glyph_style_from_computed(&crate::runtime::builtins::gauge::override_fg(
computed.clone(),
color,
)),
))
} else {
select_chrome_text(dom, id).map(|chrome| (chrome, glyph_style_from_computed(computed)))
};
if let Some((text, style)) = chrome {
paint_single_row_chrome(dom, id, &text, style, inner, buf, clip);
return;
}
if is_password_input(dom, id) {
paint_single_row_chrome(
dom,
id,
&mask_if_password(dom, id, own_text_content(dom, id)),
glyph_style_from_computed(computed),
inner,
buf,
clip,
);
return;
}
let inline_layout = dom.node(id).ext().and_then(|e| e.inline_layout.clone());
if let Some(layout) = inline_layout {
paint_lines(
dom, id, computed, &layout, inner, buf, clip, true,
);
return;
}
paint_single_row_chrome(
dom,
id,
&own_text_content(dom, id),
glyph_style_from_computed(computed),
inner,
buf,
clip,
);
}
fn paint_single_row_chrome(
dom: &Dom<TuiExt>,
id: NodeId,
body_text: &str,
body_style: Style,
inner: LayoutRect,
buf: &mut Buffer,
clip: Rect,
) {
let Some(inner_grid) = layout_rect_to_grid(inner, clip) else {
return;
};
if inner.y < clip.y as i32 || inner.y >= clip.bottom() as i32 {
return;
}
let base_y = inner.y as u16;
let start_x = inner_grid.x;
let mut cursor_x = start_x;
let budget_right = inner_grid.right();
if let Some(before) = dom.node(id).computed_before()
&& before.position == crate::layout::Position::Static
&& let Some(ref text) = before.content
{
cursor_x = paint_text(
buf,
cursor_x,
base_y,
budget_right,
text,
style_from_computed(before),
);
}
if !body_text.is_empty() {
cursor_x = paint_text(buf, cursor_x, base_y, budget_right, body_text, body_style);
}
if let Some(after) = dom.node(id).computed_after()
&& after.position == crate::layout::Position::Static
&& let Some(ref text) = after.content
{
cursor_x = paint_text(
buf,
cursor_x,
base_y,
budget_right,
text,
style_from_computed(after),
);
}
if let Some(href) = anchor_href_for(dom, id) {
let width = cursor_x.saturating_sub(start_x);
if width > 0 {
buf.set_link_range(start_x, base_y, width, Some(&href));
}
}
}
#[allow(clippy::too_many_arguments)]
fn paint_lines(
dom: &Dom<TuiExt>,
id: NodeId,
block_computed: &ComputedStyle,
layout: &crate::render::inline::InlineLayout,
inner: LayoutRect,
buf: &mut Buffer,
clip: Rect,
emit_pseudos: bool,
) {
let selection_range = dom.selection_range().filter(|r| !r.is_collapsed());
let last_line_index = layout.lines.len().saturating_sub(1);
for (line_index, line) in layout.lines.iter().enumerate() {
let line_y = inner.y + line_index as i32;
if line_y < clip.y as i32 || line_y >= clip.bottom() as i32 {
continue;
}
if line_y < inner.y || line_y >= inner.y + inner.height as i32 {
continue;
}
let mut leading_cursor: Option<u16> = None;
if emit_pseudos
&& line_index == 0
&& let Some(before) = dom.node(id).computed_before()
&& before.position == crate::layout::Position::Static
&& let Some(ref text) = before.content
{
let line_left = inner.x.max(clip.x as i32) as u16;
let line_right = clip
.right()
.min((inner.x + inner.width as i32).max(0) as u16);
let new_cursor = paint_text(
buf,
line_left,
line_y as u16,
line_right,
text,
style_from_computed(before),
);
leading_cursor = Some(new_cursor.saturating_sub(line_left));
}
for fragment in &line.fragments {
let mut frag_x = inner.x + fragment.x as i32;
if let Some(shift) = leading_cursor {
frag_x = frag_x.saturating_add(shift as i32);
}
if frag_x >= clip.right() as i32 {
continue;
}
let computed = dom
.node(fragment.node)
.ext()
.and_then(|e| e.computed.as_ref())
.cloned()
.unwrap_or_else(|| block_computed.clone());
let style = if fragment.node == id {
glyph_style_from_computed(&computed)
} else {
style_from_computed(&computed)
};
let start_x = frag_x.max(clip.x as i32) as u16;
let skip = start_x as i32 - frag_x;
let budget_right = clip
.right()
.min(inner.x.saturating_add(inner.width as i32).max(0) as u16);
if start_x >= budget_right {
continue;
}
let max_width = budget_right - start_x;
let text_to_paint: &str = if skip > 0 {
advance_text_by_cells(&fragment.text, skip as u16)
} else {
&fragment.text
};
paint_text(
buf,
start_x,
line_y as u16,
budget_right,
text_to_paint,
style,
);
if let Some(href) = anchor_href_for(dom, fragment.node) {
let written_cells = text_to_paint
.chars()
.map(|_| 1u16)
.sum::<u16>()
.min(max_width);
if written_cells > 0 {
buf.set_link_range(start_x, line_y as u16, written_cells, Some(&href));
}
}
if let Some(ref sr) = selection_range {
apply_selection_overlay(dom, buf, line_y as u16, frag_x, clip, fragment, sr);
}
}
if emit_pseudos
&& line_index == last_line_index
&& let Some(after) = dom.node(id).computed_after()
&& after.position == crate::layout::Position::Static
&& let Some(ref text) = after.content
{
let line_visual_end = line.width as i32 + leading_cursor.map(|c| c as i32).unwrap_or(0);
let after_x = inner.x + line_visual_end;
if after_x < clip.right() as i32 {
let start_x = after_x.max(clip.x as i32) as u16;
let budget_right = clip
.right()
.min((inner.x + inner.width as i32).max(0) as u16);
if start_x < budget_right {
paint_text(
buf,
start_x,
line_y as u16,
budget_right,
text,
style_from_computed(after),
);
}
}
}
}
if emit_pseudos && let Some(href) = anchor_href_for(dom, id) {
for (line_index, line) in layout.lines.iter().enumerate() {
let line_y = inner.y + line_index as i32;
if line_y < clip.y as i32 || line_y >= clip.bottom() as i32 {
continue;
}
let start_x = inner.x.max(clip.x as i32) as u16;
let cells = line.width;
if cells > 0 {
buf.set_link_range(start_x, line_y as u16, cells, Some(&href));
}
}
}
}
fn anchor_href_for(dom: &Dom<TuiExt>, id: NodeId) -> Option<String> {
let mut cur = Some(id);
while let Some(n) = cur {
let node = dom.node(n);
if node.tag_name() == Some("a")
&& let Some(href) = node.get_attribute("href")
{
return Some(href.to_string());
}
cur = node.parent_node().map(|p| p.id());
}
None
}
pub(super) fn paint_ifc(
dom: &Dom<TuiExt>,
id: NodeId,
_block_computed: &ComputedStyle,
inner: LayoutRect,
buf: &mut Buffer,
clip: Rect,
) {
let Some(inline_layout) = dom.node(id).ext().and_then(|e| e.inline_layout.clone()) else {
return;
};
paint_inline_layout(dom, &inline_layout, inner, id, buf, clip);
}
pub(super) fn paint_anonymous_blocks(
dom: &Dom<TuiExt>,
container_id: NodeId,
buf: &mut Buffer,
clip: Rect,
) {
let anons: Vec<crate::ext::AnonymousIfc> = dom
.node(container_id)
.ext()
.map(|e| e.anonymous_blocks.clone())
.unwrap_or_default();
for anon in &anons {
paint_inline_layout(dom, &anon.inline_layout, anon.rect, container_id, buf, clip);
}
}
fn paint_inline_layout(
dom: &Dom<TuiExt>,
inline_layout: &crate::render::inline::InlineLayout,
inner: LayoutRect,
bg_dedup_owner: NodeId,
buf: &mut Buffer,
clip: Rect,
) {
let selection_range = dom.selection_range().filter(|r| !r.is_collapsed());
for (line_index, line) in inline_layout.lines.iter().enumerate() {
let line_y = inner.y + line_index as i32;
if line_y < clip.y as i32 || line_y >= clip.bottom() as i32 {
continue;
}
if line_y < inner.y || line_y >= inner.y + inner.height as i32 {
continue;
}
for fragment in &line.fragments {
let frag_x = inner.x + fragment.x as i32;
if frag_x >= clip.right() as i32 {
continue;
}
if fragment.atomic {
let atom_rect = LayoutRect::new(frag_x, line_y, fragment.width, 1);
let atom_computed = dom
.node(fragment.node)
.ext()
.and_then(|e| e.computed.as_ref())
.cloned()
.unwrap_or_else(ComputedStyle::initial);
paint_inline_content(dom, fragment.node, &atom_computed, atom_rect, buf, clip);
continue;
}
let computed = dom
.node(fragment.node)
.ext()
.and_then(|e| e.computed.as_ref())
.cloned()
.unwrap_or_else(ComputedStyle::initial);
let style = if fragment.node == bg_dedup_owner {
glyph_style_from_computed(&computed)
} else {
style_from_computed(&computed)
};
let start_x = frag_x.max(clip.x as i32) as u16;
let skip = start_x as i32 - frag_x;
let budget_right = clip
.right()
.min(inner.x.saturating_add(inner.width as i32).max(0) as u16);
if start_x >= budget_right {
continue;
}
let max_width = budget_right - start_x;
let text_to_paint: &str = if skip > 0 {
advance_text_by_cells(&fragment.text, skip as u16)
} else {
&fragment.text
};
paint_text(
buf,
start_x,
line_y as u16,
budget_right,
text_to_paint,
style,
);
if let Some(href) = anchor_href_for(dom, fragment.node) {
let written_cells = text_to_paint
.chars()
.map(|_| 1u16)
.sum::<u16>()
.min(max_width);
if written_cells > 0 {
buf.set_link_range(start_x, line_y as u16, written_cells, Some(&href));
}
}
if let Some(ref sr) = selection_range {
apply_selection_overlay(dom, buf, line_y as u16, frag_x, clip, fragment, sr);
}
}
}
}
pub(super) fn paint_caret_if_editable(dom: &Dom<TuiExt>, buf: &mut Buffer, id: NodeId, clip: Rect) {
use crate::layout::{CaretColor, CaretTextColor};
let sel = match dom.selection() {
Some(s) if s.is_collapsed() => s,
_ => return,
};
let Some(focused) = dom.focused() else {
return;
};
if crate::node::nearest_editable_ancestor(dom, focused).is_none() {
return;
};
let caret_ifc = inline_flow_container(dom, sel.focus.node);
if caret_ifc != Some(id) {
return;
}
let computed = match dom.node(focused).ext().and_then(|e| e.computed.as_ref()) {
Some(c) => c,
None => return,
};
if matches!(computed.caret_color, CaretColor::Transparent) {
return;
}
let Some((x, y)) = crate::runtime::editing::caret::cell_of_position(dom, sel.focus) else {
return;
};
if x < clip.x || x >= clip.right() || y < clip.y || y >= clip.bottom() {
return;
}
let resolve_reset_fg = |c: crate::Color| match c {
crate::Color::Reset => crate::Color::Rgb(0xFF, 0xFF, 0xFF),
other => other,
};
let resolve_reset_bg = |c: crate::Color| match c {
crate::Color::Reset => crate::Color::Rgb(0x00, 0x00, 0x00),
other => other,
};
let cascaded_fg = resolve_reset_fg(computed.fg);
let cascaded_bg = resolve_reset_bg(computed.bg);
let under_mod = buf.cell(x, y).map(|c| c.modifier).unwrap_or_default();
let caret_bg = match &computed.caret_color {
CaretColor::Auto => cascaded_fg,
CaretColor::Transparent => return, CaretColor::Color(tc) => match tc {
crate::TuiColor::Literal(c) => *c,
crate::TuiColor::Var { .. } => cascaded_fg,
},
};
let caret_fg = match &computed.caret_text_color {
CaretTextColor::Auto => cascaded_bg,
CaretTextColor::Color(tc) => match tc {
crate::TuiColor::Literal(c) => *c,
crate::TuiColor::Var { .. } => cascaded_bg,
},
};
let mut new_style = Style::new().fg(caret_fg).bg(caret_bg);
for m in [
Modifier::BOLD,
Modifier::ITALIC,
Modifier::UNDERLINED,
Modifier::SLOW_BLINK,
Modifier::RAPID_BLINK,
Modifier::HIDDEN,
Modifier::CROSSED_OUT,
] {
if under_mod.contains(m) {
new_style = new_style.add_modifier(m);
}
}
buf.set_style(x, y, new_style);
}
fn apply_selection_overlay(
dom: &Dom<TuiExt>,
buf: &mut Buffer,
line_y: u16,
frag_x: i32,
clip: Rect,
fragment: &InlineFragment,
range: &Range,
) {
if crate::runtime::selection::user_select::has_none_ancestor(dom, fragment.text_node) {
return;
}
let Some((byte_start, byte_end)) = selection_byte_range_in(dom, range, fragment.text_node)
else {
return;
};
let frag_start = fragment.source_byte_offset;
let frag_end = fragment.source_byte_offset + fragment.text.len();
let local_start = byte_start.max(frag_start);
let local_end = byte_end.min(frag_end);
if local_start >= local_end {
return;
}
let off_start = local_start - frag_start;
let off_end = local_end - frag_start;
let cell_start = cells_before_byte(&fragment.text, off_start);
let cell_end = cells_before_byte(&fragment.text, off_end);
if cell_start >= cell_end {
return;
}
let overlay = match nearest_selection_style(dom, fragment.text_node) {
Some(c) => style_from_computed(c),
None => Style::new(),
};
for c in cell_start..cell_end {
let x = (frag_x + c as i32) as u16;
if x < clip.x || x >= clip.right() {
continue;
}
buf.set_style(x, line_y, overlay);
}
}
fn nearest_selection_style(dom: &Dom<TuiExt>, text_node: NodeId) -> Option<&ComputedStyle> {
let mut cur = dom.node(text_node).parent_node().map(|p| p.id());
while let Some(id) = cur {
if let Some(ext) = dom.node(id).ext()
&& let Some(sel) = ext.computed_selection.as_ref()
{
return Some(sel);
}
cur = dom.node(id).parent_node().map(|p| p.id());
}
None
}
fn selection_byte_range_in(
dom: &Dom<TuiExt>,
range: &Range,
text_node: NodeId,
) -> Option<(usize, usize)> {
let is_start = range.start.node == text_node;
let is_end = range.end.node == text_node;
if is_start && is_end {
return Some((range.start.offset, range.end.offset));
}
let text_len = dom
.node(text_node)
.node_value()
.map(|s| s.len())
.unwrap_or(0);
if is_start {
return Some((range.start.offset, text_len));
}
if is_end {
return Some((0, range.end.offset));
}
let after_start = {
let p = dom.compare_document_position(range.start.node, text_node);
p.contains(DocumentPosition::FOLLOWING) || p.contains(DocumentPosition::CONTAINS)
};
let before_end = {
let p = dom.compare_document_position(range.end.node, text_node);
p.contains(DocumentPosition::PRECEDING) || p.contains(DocumentPosition::CONTAINS)
};
if after_start && before_end {
Some((0, text_len))
} else {
None
}
}
fn cells_before_byte(text: &str, target: usize) -> u16 {
let mut cells: u16 = 0;
for (idx, g) in text.grapheme_indices(true) {
if idx >= target {
return cells;
}
cells = cells.saturating_add(UnicodeWidthStr::width(g) as u16);
}
cells
}
fn advance_text_by_cells(text: &str, cells: u16) -> &str {
let mut consumed: u16 = 0;
let mut byte_pos: usize = 0;
for (idx, g) in text.grapheme_indices(true) {
if consumed >= cells {
byte_pos = idx;
return &text[byte_pos..];
}
let w = UnicodeWidthStr::width(g) as u16;
consumed = consumed.saturating_add(w);
byte_pos = idx + g.len();
}
&text[byte_pos..]
}
fn own_text_content(dom: &Dom<TuiExt>, id: NodeId) -> String {
let mut out = String::new();
for child in dom.node(id).child_nodes() {
if child.node_type() == NodeType::Text
&& let Some(data) = child.node_value()
{
out.push_str(data);
}
}
out
}
fn select_chrome_text(dom: &Dom<TuiExt>, id: NodeId) -> Option<String> {
if dom.node(id).tag_name() != Some("select") {
return None;
}
if !crate::runtime::builtins::select::is_dropdown(dom, id) {
return None;
}
if crate::runtime::builtins::select::is_open(dom, id) {
return None;
}
let selected = crate::runtime::builtins::select::selected_options(dom, id);
let label = selected
.first()
.map(|&opt| crate::runtime::builtins::select::option_label(dom, opt))
.unwrap_or_default();
Some(label)
}
fn is_password_input(dom: &Dom<TuiExt>, id: NodeId) -> bool {
let node = dom.node(id);
node.tag_name() == Some("input") && node.get_attribute("type") == Some("password")
}
fn mask_if_password(dom: &Dom<TuiExt>, id: NodeId, text: String) -> String {
if !is_password_input(dom, id) {
return text;
}
text.graphemes(true).map(|_| "\u{2022}").collect()
}