use super::flexbox::inner_area;
use super::*;
pub(crate) fn render(node: &LayoutNode, buf: &mut Buffer) {
render_inner(node, buf, 0, 0, None, 0);
buf.clip_stack.clear();
for overlay in &node.overlays {
if overlay.modal {
let modal_rect = Rect::new(
overlay.node.pos.0,
overlay.node.pos.1,
overlay.node.size.0,
overlay.node.size.1,
);
if modal_rect.width == 0 || modal_rect.height == 0 {
dim_entire_buffer(buf);
} else {
dim_buffer_around(buf, modal_rect);
}
}
render_inner(&overlay.node, buf, 0, 0, None, 0);
}
}
fn dim_entire_buffer(buf: &mut Buffer) {
for y in buf.area.y..buf.area.bottom() {
for x in buf.area.x..buf.area.right() {
let cell = buf.get_mut(x, y);
cell.style.modifiers |= crate::style::Modifiers::DIM;
}
}
}
#[doc(hidden)]
pub(crate) fn __bench_dim_buffer_around(buf: &mut Buffer, modal_rect: Rect) {
dim_buffer_around(buf, modal_rect)
}
fn dim_buffer_around(buf: &mut Buffer, modal_rect: Rect) {
let area = buf.area;
let clip_x = modal_rect.x.max(area.x);
let clip_y = modal_rect.y.max(area.y);
let clip_right = modal_rect.right().min(area.right());
let clip_bottom = modal_rect.bottom().min(area.bottom());
if clip_right <= clip_x || clip_bottom <= clip_y {
dim_entire_buffer(buf);
return;
}
for y in area.y..clip_y {
for x in area.x..area.right() {
let cell = buf.get_mut(x, y);
cell.style.modifiers |= crate::style::Modifiers::DIM;
}
}
for y in clip_bottom..area.bottom() {
for x in area.x..area.right() {
let cell = buf.get_mut(x, y);
cell.style.modifiers |= crate::style::Modifiers::DIM;
}
}
for y in clip_y..clip_bottom {
for x in area.x..clip_x {
let cell = buf.get_mut(x, y);
cell.style.modifiers |= crate::style::Modifiers::DIM;
}
}
for y in clip_y..clip_bottom {
for x in clip_right..area.right() {
let cell = buf.get_mut(x, y);
cell.style.modifiers |= crate::style::Modifiers::DIM;
}
}
}
#[derive(Debug, Clone, Copy)]
enum LayerTint {
Base,
Overlay,
Modal,
}
#[derive(Debug, Clone, Copy, Default)]
struct LayerCounts {
base: u32,
overlay: u32,
modal: u32,
}
impl LayerCounts {
fn total(self) -> u32 {
self.base
.saturating_add(self.overlay)
.saturating_add(self.modal)
}
}
pub(crate) fn render_debug_overlay(
node: &LayoutNode,
buf: &mut Buffer,
frame_time_us: u64,
fps: f32,
layer: crate::DebugLayer,
) {
let walk_base = !matches!(layer, crate::DebugLayer::TopMost) || node.overlays.is_empty();
let walk_overlays = !matches!(layer, crate::DebugLayer::BaseOnly);
if walk_base {
for child in &node.children {
render_debug_overlay_inner(child, buf, 0, 0, 0, LayerTint::Base);
}
}
if walk_overlays {
for overlay in &node.overlays {
let tint = if overlay.modal {
LayerTint::Modal
} else {
LayerTint::Overlay
};
render_debug_overlay_inner(&overlay.node, buf, 0, 0, 0, tint);
}
}
render_debug_status_bar(node, buf, frame_time_us, fps);
}
fn render_debug_status_bar(node: &LayoutNode, buf: &mut Buffer, frame_time_us: u64, fps: f32) {
if buf.area.height == 0 || buf.area.width == 0 {
return;
}
let counts = count_leaf_widgets_layered(node);
let widgets = counts.total();
let width = buf.area.width;
let height = buf.area.height;
let y = buf.area.bottom() - 1;
let style = Style::new().fg(Color::Black).bg(Color::Yellow).bold();
let mut breakdown_parts: Vec<String> = Vec::with_capacity(3);
if counts.base > 0 {
breakdown_parts.push(format!("{} base", counts.base));
}
if counts.overlay > 0 {
breakdown_parts.push(format!("{} overlay", counts.overlay));
}
if counts.modal > 0 {
breakdown_parts.push(format!("{} modal", counts.modal));
}
let breakdown = if breakdown_parts.len() > 1 {
format!(" ({})", breakdown_parts.join(", "))
} else {
String::new()
};
let status = format!(
"[SLT Debug] {}x{} | {} widgets{} | {:.1}ms | {:.0}fps",
width,
height,
widgets,
breakdown,
frame_time_us as f64 / 1_000.0,
fps.max(0.0)
);
let row_fill = " ".repeat(width as usize);
buf.set_string(buf.area.x, y, &row_fill, style);
buf.set_string(buf.area.x, y, &status, style);
}
fn count_leaf_widgets_layered(node: &LayoutNode) -> LayerCounts {
let base: u32 = node.children.iter().map(count_leaf_widgets).sum();
let mut overlay: u32 = 0;
let mut modal: u32 = 0;
for layer in &node.overlays {
let n = count_leaf_widgets(&layer.node);
if layer.modal {
modal = modal.saturating_add(n);
} else {
overlay = overlay.saturating_add(n);
}
}
LayerCounts {
base,
overlay,
modal,
}
}
fn count_leaf_widgets(node: &LayoutNode) -> u32 {
let mut total = if node.children.is_empty() {
match node.kind {
NodeKind::Spacer => 0,
_ => 1,
}
} else {
node.children.iter().map(count_leaf_widgets).sum()
};
for overlay in &node.overlays {
total = total.saturating_add(count_leaf_widgets(&overlay.node));
}
total
}
fn render_debug_overlay_inner(
node: &LayoutNode,
buf: &mut Buffer,
depth: u32,
x_offset: u32,
y_offset: u32,
tint: LayerTint,
) {
let child_y_offset = if node.is_scrollable {
y_offset.saturating_add(node.scroll_offset)
} else {
y_offset
};
let child_x_offset = if node.is_scrollable {
x_offset.saturating_add(node.scroll_offset_x)
} else {
x_offset
};
if let NodeKind::Container(_) = node.kind {
let sy = screen_y(node.pos.1, y_offset);
let sx = screen_x(node.pos.0, x_offset);
if sy + node.size.1 as i64 > 0 && sx + node.size.0 as i64 > 0 {
let color = debug_color_for_depth(tint, depth);
let style = Style::new().fg(color);
let clamped_y = sy.max(0) as u32;
let clamped_x = sx.max(0) as u32;
draw_debug_border(clamped_x, clamped_y, node.size.0, node.size.1, buf, style);
if sy >= 0 && sx >= 0 {
buf.set_string(clamped_x, clamped_y, &depth.to_string(), style);
}
}
}
if node.is_scrollable {
if let Some(area) = visible_area(node, x_offset, y_offset) {
let inner = inner_area(node, area);
buf.push_clip(inner);
for child in &node.children {
render_debug_overlay_inner(
child,
buf,
depth.saturating_add(1),
child_x_offset,
child_y_offset,
tint,
);
}
buf.pop_clip();
}
} else {
for child in &node.children {
render_debug_overlay_inner(
child,
buf,
depth.saturating_add(1),
child_x_offset,
child_y_offset,
tint,
);
}
}
}
pub(crate) struct InspectorFocus<'a> {
pub focus_index: usize,
pub focus_count: usize,
pub names: &'a std::collections::HashMap<String, usize>,
pub theme: &'a crate::style::Theme,
}
pub(crate) fn render_inspector(root: &LayoutNode, buf: &mut Buffer, focus: &InspectorFocus<'_>) {
if buf.area.width == 0 || buf.area.height == 0 {
return;
}
if focus.focus_count == 0 {
render_inspector_notice(buf, focus.theme, "[SLT Inspector] no focusable widgets");
return;
}
let current = focus.focus_index % focus.focus_count;
if let Some(node) = find_focused_node(root, current) {
render_style_panel(node, buf, focus, current);
}
render_focus_chain_panel(buf, focus, current);
}
fn find_focused_node(node: &LayoutNode, focus_id: usize) -> Option<&LayoutNode> {
if node.focus_id == Some(focus_id) {
return Some(node);
}
node.children
.iter()
.find_map(|c| find_focused_node(c, focus_id))
.or_else(|| {
node.overlays
.iter()
.find_map(|o| find_focused_node(&o.node, focus_id))
})
}
fn fmt_color(c: Color) -> String {
match c {
Color::Reset => "default".to_string(),
Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
Color::Indexed(i) => format!("idx({i})"),
named => format!("{named:?}"),
}
}
fn fmt_opt_color(c: Option<Color>) -> String {
c.map(fmt_color).unwrap_or_else(|| "default".to_string())
}
fn render_inspector_notice(buf: &mut Buffer, theme: &crate::style::Theme, msg: &str) {
let style = Style::new().fg(theme.surface_text).bg(theme.surface);
let width = buf.area.width as usize;
let fill: String = " ".repeat(msg.chars().count().min(width));
buf.set_string(buf.area.x, buf.area.y, &fill, style);
buf.set_string(buf.area.x, buf.area.y, msg, style);
}
fn render_style_panel(
node: &LayoutNode,
buf: &mut Buffer,
focus: &InspectorFocus<'_>,
current: usize,
) {
let theme = focus.theme;
let text = Style::new().fg(theme.surface_text).bg(theme.surface);
let head = Style::new().fg(theme.border).bg(theme.surface).bold();
let name = focus
.names
.iter()
.find_map(|(n, &i)| (i == current).then_some(n.as_str()))
.unwrap_or("<unnamed>");
let p = node.padding;
let mut lines: Vec<(String, Style)> = Vec::with_capacity(7);
lines.push(("[SLT Inspector] focused widget".to_string(), head));
lines.push((format!("index: {current} name: {name}"), text));
lines.push((
format!(
"rect: {},{} {}x{}",
node.pos.0, node.pos.1, node.size.0, node.size.1
),
text,
));
lines.push((format!("fg: {}", fmt_opt_color(node.style.fg)), text));
lines.push((
format!("bg: {}", fmt_opt_color(node.bg_color.or(node.style.bg))),
text,
));
lines.push((
format!("padding: l{} r{} t{} b{}", p.left, p.right, p.top, p.bottom),
text,
));
lines.push((format!("constraints: {:?}", node.constraints), text));
let max_w = lines
.iter()
.map(|(s, _)| s.chars().count())
.max()
.unwrap_or(0);
let width = (max_w as u32).min(buf.area.width);
let x = buf.area.x;
for (i, (line, style)) in lines.iter().enumerate() {
let y = buf.area.y + i as u32;
if y >= buf.area.bottom() {
break;
}
let fill: String = " ".repeat(width as usize);
buf.set_string(x, y, &fill, *style);
buf.set_string(x, y, line, *style);
}
}
fn render_focus_chain_panel(buf: &mut Buffer, focus: &InspectorFocus<'_>, current: usize) {
let theme = focus.theme;
let text = Style::new().fg(theme.surface_text).bg(theme.surface);
let head = Style::new().fg(theme.border).bg(theme.surface).bold();
let cursor = Style::new().fg(theme.border).bg(theme.surface).bold();
let mut lines: Vec<(String, Style)> = Vec::with_capacity(focus.focus_count + 1);
lines.push((
format!("[SLT Inspector] focus chain ({})", focus.focus_count),
head,
));
let max_rows = buf.area.height.saturating_sub(1) as usize;
for idx in 0..focus.focus_count.min(max_rows) {
let marker = if idx == current { ">" } else { " " };
let name = focus
.names
.iter()
.find_map(|(n, &i)| (i == idx).then_some(n.as_str()));
let line = match name {
Some(n) => format!("{marker} {idx}: {n}"),
None => format!("{marker} {idx}"),
};
let style = if idx == current { cursor } else { text };
lines.push((line, style));
}
let max_w = lines
.iter()
.map(|(s, _)| s.chars().count())
.max()
.unwrap_or(0) as u32;
let panel_w = max_w.min(buf.area.width);
let x = buf.area.right().saturating_sub(panel_w).max(buf.area.x);
for (i, (line, style)) in lines.iter().enumerate() {
let y = buf.area.y + i as u32;
if y >= buf.area.bottom() {
break;
}
let fill: String = " ".repeat(panel_w as usize);
buf.set_string(x, y, &fill, *style);
buf.set_string(x, y, line, *style);
}
}
fn debug_color_for_depth(tint: LayerTint, depth: u32) -> Color {
let base = match tint {
LayerTint::Base => Color::Rgb(64, 200, 64),
LayerTint::Overlay => Color::Rgb(220, 80, 80),
LayerTint::Modal => Color::Rgb(80, 140, 220),
};
match depth {
0..=1 => base,
2..=3 => base.lighten(0.25),
_ => base.lighten(0.5),
}
}
fn draw_debug_border(x: u32, y: u32, w: u32, h: u32, buf: &mut Buffer, style: Style) {
if w == 0 || h == 0 {
return;
}
let right = x + w - 1;
let bottom = y + h - 1;
if w == 1 && h == 1 {
buf.set_char(x, y, '┼', style);
return;
}
if h == 1 {
for xx in x..=right {
buf.set_char(xx, y, '─', style);
}
return;
}
if w == 1 {
for yy in y..=bottom {
buf.set_char(x, yy, '│', style);
}
return;
}
buf.set_char(x, y, '┌', style);
buf.set_char(right, y, '┐', style);
buf.set_char(x, bottom, '└', style);
buf.set_char(right, bottom, '┘', style);
for xx in (x + 1)..right {
buf.set_char(xx, y, '─', style);
buf.set_char(xx, bottom, '─', style);
}
for yy in (y + 1)..bottom {
buf.set_char(x, yy, '│', style);
buf.set_char(right, yy, '│', style);
}
}
fn screen_y(layout_y: u32, y_offset: u32) -> i64 {
layout_y as i64 - y_offset as i64
}
fn screen_x(layout_x: u32, x_offset: u32) -> i64 {
layout_x as i64 - x_offset as i64
}
fn set_string_clipped_x(
buf: &mut Buffer,
screen_x: i64,
y: u32,
text: &str,
style: Style,
link: Option<&str>,
) -> i64 {
let full_width = UnicodeWidthStr::width(text) as i64;
if screen_x >= 0 {
let sx = screen_x as u32;
if let Some(url) = link {
buf.set_string_linked(sx, y, text, style, url);
} else {
buf.set_string(sx, y, text, style);
}
return screen_x + full_width;
}
let skip = (-screen_x) as u32;
let mut consumed: u32 = 0;
let mut byte_start = text.len();
for (idx, g) in text.grapheme_indices(true) {
if consumed >= skip {
byte_start = idx;
break;
}
consumed += UnicodeWidthStr::width(g) as u32;
}
if byte_start >= text.len() {
return screen_x + full_width;
}
let visible = &text[byte_start..];
if let Some(url) = link {
buf.set_string_linked(0, y, visible, style, url);
} else {
buf.set_string(0, y, visible, style);
}
screen_x + full_width
}
fn visible_area(node: &LayoutNode, x_offset: u32, y_offset: u32) -> Option<Rect> {
let sy = screen_y(node.pos.1, y_offset);
let bottom = sy + node.size.1 as i64;
let sx = screen_x(node.pos.0, x_offset);
let right = sx + node.size.0 as i64;
if bottom <= 0 || right <= 0 || node.size.0 == 0 || node.size.1 == 0 {
return None;
}
let clamped_y = sy.max(0) as u32;
let clamped_h = (bottom as u32).saturating_sub(clamped_y);
let clamped_x = sx.max(0) as u32;
let clamped_w = (right as u32).saturating_sub(clamped_x);
Some(Rect::new(clamped_x, clamped_y, clamped_w, clamped_h))
}
fn render_inner(
node: &LayoutNode,
buf: &mut Buffer,
x_offset: u32,
y_offset: u32,
parent_bg: Option<Color>,
depth: usize,
) {
if depth > super::tree::MAX_LAYOUT_DEPTH {
panic!(
"layout tree depth exceeds {}: check for recursive container nesting",
super::tree::MAX_LAYOUT_DEPTH
);
}
if node.size.0 == 0 || node.size.1 == 0 {
return;
}
let sy = screen_y(node.pos.1, y_offset);
let sx = screen_x(node.pos.0, x_offset);
let ex = sx.saturating_add(i64::from(node.size.0));
let ey = sy.saturating_add(i64::from(node.size.1));
let viewport_left = i64::from(buf.area.x);
let viewport_top = i64::from(buf.area.y);
let viewport_right = viewport_left.saturating_add(i64::from(buf.area.width));
let viewport_bottom = viewport_top.saturating_add(i64::from(buf.area.height));
if ex <= viewport_left || ey <= viewport_top || sx >= viewport_right || sy >= viewport_bottom {
return;
}
match node.kind {
NodeKind::Text => {
let Some(td) = node.text_data() else {
return;
};
if let Some(ref segs) = td.segments {
if node.wrap {
let fallback;
let wrapped = if let Some(cached) = &td.cached_wrapped_segments {
cached.as_slice()
} else {
fallback = wrap_segments(segs, node.size.0);
&fallback
};
for (i, line_segs) in wrapped.iter().enumerate() {
let line_y = sy + i as i64;
if line_y < 0 {
continue;
}
let mut x = sx;
for (text, style) in line_segs {
let mut s = *style;
if s.bg.is_none() {
s.bg = parent_bg;
}
x = set_string_clipped_x(buf, x, line_y as u32, text, s, None);
}
}
} else {
if sy < 0 {
return;
}
let mut x = sx;
for (text, style) in segs {
let mut s = *style;
if s.bg.is_none() {
s.bg = parent_bg;
}
x = set_string_clipped_x(buf, x, sy as u32, text, s, None);
}
}
} else if let Some(ref text) = td.content {
let mut style = node.style;
if style.bg.is_none() {
style.bg = parent_bg;
}
if node.wrap {
let fallback;
let lines = if let Some(cached) = &td.cached_wrapped {
cached.as_slice()
} else {
fallback = wrap_lines(text, node.size.0);
fallback.as_slice()
};
for (i, line) in lines.iter().enumerate() {
let line_y = sy + i as i64;
if line_y < 0 {
continue;
}
let text_width = UnicodeWidthStr::width(line.as_str()) as u32;
let x_align = if text_width < node.size.0 {
match node.align {
Align::Start => 0,
Align::Center => (node.size.0 - text_width) / 2,
Align::End => node.size.0 - text_width,
}
} else {
0
};
set_string_clipped_x(
buf,
sx + i64::from(x_align),
line_y as u32,
line,
style,
None,
);
}
} else {
if sy < 0 {
return;
}
let text_width = UnicodeWidthStr::width(text.as_str()) as u32;
if node.truncate && text_width > node.size.0 && node.size.0 > 1 {
let truncated = truncate_with_ellipsis(text, node.size.0 as usize);
let trunc_width = UnicodeWidthStr::width(truncated.as_str()) as u32;
let x_align = if trunc_width < node.size.0 {
match node.align {
Align::Start => 0,
Align::Center => (node.size.0 - trunc_width) / 2,
Align::End => node.size.0 - trunc_width,
}
} else {
0
};
set_string_clipped_x(
buf,
sx + i64::from(x_align),
sy as u32,
&truncated,
style,
node.link_url.as_deref(),
);
} else {
let x_align = if text_width < node.size.0 {
match node.align {
Align::Start => 0,
Align::Center => (node.size.0 - text_width) / 2,
Align::End => node.size.0 - text_width,
}
} else {
0
};
let draw_x = sx + i64::from(x_align);
if let Some(cursor_offset) = td.cursor_offset {
let cursor_x = text
.chars()
.take(cursor_offset)
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0) as u32)
.sum::<u32>();
let cursor_screen_x = draw_x + i64::from(cursor_x);
if cursor_screen_x >= 0 {
buf.set_cursor_pos(cursor_screen_x as u32, sy as u32);
}
}
set_string_clipped_x(
buf,
draw_x,
sy as u32,
text,
style,
node.link_url.as_deref(),
);
}
}
}
}
NodeKind::Spacer | NodeKind::RawDraw(_) => {}
NodeKind::Container(_) => {
if let Some(color) = node.bg_color {
if let Some(area) = visible_area(node, x_offset, y_offset) {
let fill_style = Style::new().bg(color);
for y in area.y..area.bottom() {
for x in area.x..area.right() {
buf.set_string(x, y, " ", fill_style);
}
}
}
}
let child_bg = node.bg_color.or(parent_bg);
render_container_border(node, buf, x_offset, y_offset, child_bg);
if node.is_scrollable {
let Some(area) = visible_area(node, x_offset, y_offset) else {
return;
};
let inner = inner_area(node, area);
let child_y_offset = y_offset.saturating_add(node.scroll_offset);
let child_x_offset = x_offset.saturating_add(node.scroll_offset_x);
let render_y_start = inner.y as i64;
let render_y_end = inner.bottom() as i64;
let render_x_start = inner.x as i64;
let render_x_end = inner.right() as i64;
buf.push_clip(inner);
for child in &node.children {
let child_top = child.pos.1 as i64 - child_y_offset as i64;
let child_bottom = child_top + child.size.1 as i64;
if child_bottom <= render_y_start || child_top >= render_y_end {
continue;
}
let child_left = child.pos.0 as i64 - child_x_offset as i64;
let child_right = child_left + child.size.0 as i64;
if child_right <= render_x_start || child_left >= render_x_end {
continue;
}
render_inner(
child,
buf,
child_x_offset,
child_y_offset,
child_bg,
depth + 1,
);
}
buf.pop_clip();
render_scroll_indicators(node, inner, buf, child_bg);
} else {
let Some(area) = visible_area(node, x_offset, y_offset) else {
return;
};
let clip = inner_area(node, area);
buf.push_clip(clip);
for child in &node.children {
render_inner(child, buf, x_offset, y_offset, child_bg, depth + 1);
}
buf.pop_clip();
}
}
}
}
fn render_container_border(
node: &LayoutNode,
buf: &mut Buffer,
x_offset: u32,
y_offset: u32,
inherit_bg: Option<Color>,
) {
if node.border_inset() == 0 {
return;
}
let Some(border) = node.border else {
return;
};
let sides = node.border_sides;
let chars = border.chars();
let w = node.size.0;
let h = node.size.1;
if w == 0 || h == 0 {
return;
}
let mut style = node.border_style;
if style.bg.is_none() {
style.bg = inherit_bg;
}
let top_i = screen_y(node.pos.1, y_offset);
let bottom_i = top_i + h as i64 - 1;
if bottom_i < 0 {
return;
}
let left_i = screen_x(node.pos.0, x_offset);
let right_i = left_i + w as i64 - 1;
if right_i < 0 {
return;
}
let set_char_at = |buf: &mut Buffer, col: i64, y: u32, ch: char| {
if col >= 0 {
buf.set_char(col as u32, y, ch, style);
}
};
let h_start = left_i.max(0) as u32;
let h_end = right_i as u32;
if sides.top && top_i >= 0 {
let y = top_i as u32;
for xx in h_start..=h_end {
buf.set_char(xx, y, chars.h, style);
}
}
if sides.bottom {
let y = bottom_i as u32;
for xx in h_start..=h_end {
buf.set_char(xx, y, chars.h, style);
}
}
if sides.left {
let vert_start = top_i.max(0) as u32;
let vert_end = bottom_i as u32;
for yy in vert_start..=vert_end {
set_char_at(buf, left_i, yy, chars.v);
}
}
if sides.right {
let vert_start = top_i.max(0) as u32;
let vert_end = bottom_i as u32;
for yy in vert_start..=vert_end {
set_char_at(buf, right_i, yy, chars.v);
}
}
if top_i >= 0 {
let y = top_i as u32;
let tl = match (sides.top, sides.left) {
(true, true) => Some(chars.tl),
(true, false) => Some(chars.h),
(false, true) => Some(chars.v),
(false, false) => None,
};
if let Some(ch) = tl {
set_char_at(buf, left_i, y, ch);
}
let tr = match (sides.top, sides.right) {
(true, true) => Some(chars.tr),
(true, false) => Some(chars.h),
(false, true) => Some(chars.v),
(false, false) => None,
};
if let Some(ch) = tr {
set_char_at(buf, right_i, y, ch);
}
}
let viewport_bottom = i64::from(buf.area.y).saturating_add(i64::from(buf.area.height));
if bottom_i < viewport_bottom {
let y = bottom_i as u32;
let bl = match (sides.bottom, sides.left) {
(true, true) => Some(chars.bl),
(true, false) => Some(chars.h),
(false, true) => Some(chars.v),
(false, false) => None,
};
if let Some(ch) = bl {
set_char_at(buf, left_i, y, ch);
}
let br = match (sides.bottom, sides.right) {
(true, true) => Some(chars.br),
(true, false) => Some(chars.h),
(false, true) => Some(chars.v),
(false, false) => None,
};
if let Some(ch) = br {
set_char_at(buf, right_i, y, ch);
}
}
if sides.top && top_i >= 0 {
if let Some((title, title_style)) = &node.title {
let mut ts = *title_style;
if ts.bg.is_none() {
ts.bg = inherit_bg;
}
let y = top_i as u32;
let title_x = left_i + 2;
let title_right = if sides.right { right_i - 1 } else { right_i };
if title_x <= title_right && title_right >= 0 {
let max_width = (title_right - title_x + 1).max(0) as usize;
let mut trimmed = String::new();
let mut col_used = 0usize;
for ch in title.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if col_used + cw > max_width {
break;
}
trimmed.push(ch);
col_used += cw;
}
set_string_clipped_x(buf, title_x, y, &trimmed, ts, None);
}
}
}
}
fn render_scroll_indicators(
node: &LayoutNode,
inner: Rect,
buf: &mut Buffer,
inherit_bg: Option<Color>,
) {
if inner.width == 0 || inner.height == 0 {
return;
}
let mut style = node.border_style;
if style.bg.is_none() {
style.bg = inherit_bg;
}
let indicator_x = inner.right() - 1;
if node.scroll_offset > 0 {
buf.set_char(indicator_x, inner.y, '▲', style);
}
if node.scroll_offset.saturating_add(inner.height) < node.content_height {
buf.set_char(indicator_x, inner.bottom() - 1, '▼', style);
}
let indicator_y = inner.bottom() - 1;
if node.scroll_offset_x > 0 {
buf.set_char(inner.x, indicator_y, '◀', style);
}
if node.scroll_offset_x.saturating_add(inner.width) < node.content_width {
buf.set_char(inner.right() - 1, indicator_y, '▶', style);
}
}
pub(super) fn truncate_with_ellipsis(text: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
if max_width == 1 {
return "\u{2026}".to_string();
}
let target = max_width - 1;
let mut result = String::new();
let mut width = 0;
for g in text.graphemes(true) {
let ch_width = UnicodeWidthStr::width(g);
if width + ch_width > target {
break;
}
result.push_str(g);
width += ch_width;
}
result.push('\u{2026}');
result
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::tree::default_container_config;
use super::*;
#[test]
fn render_tracks_cursor_position_from_text_node() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 4));
let mut node = LayoutNode::text(
"ab▎cd".to_string(),
Style::new(),
0,
Align::Start,
(Some(2), false, false),
Margin::default(),
Constraints::default(),
);
node.pos = (3, 1);
node.size = (5, 1);
render(&node, &mut buf);
assert_eq!(buf.cursor_pos(), Some((5, 1)));
}
#[test]
fn border_title_cjk_truncates_within_box() {
use crate::style::{Align, Border, Constraints, Justify, Margin, Padding};
use unicode_width::UnicodeWidthStr;
let mut root = LayoutNode::container(
Direction::Row,
super::tree::ContainerConfig {
gap: 0,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: Some(Border::Single),
border_sides: BorderSides::all(),
border_style: Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Margin::default(),
constraints: Constraints::default(),
title: Some(("설정창".to_string(), Style::new())),
grow: 0,
},
);
let area = Rect::new(0, 0, 8, 4);
super::flexbox::compute(&mut root, area);
let mut buf = Buffer::empty(area);
render(&root, &mut buf);
let top_row: String = (0..8u32)
.map(|x| buf.get(x, 0).symbol.chars().next().unwrap_or(' '))
.collect();
let right_border = buf.get(7, 0).symbol.chars().next().unwrap_or(' ');
assert_eq!(
right_border, '┐',
"right border overwritten by CJK title overflow; top row: {top_row:?}"
);
assert_eq!(buf.get(2, 0).symbol.chars().next(), Some('설'));
assert_eq!(buf.get(4, 0).symbol.chars().next(), Some('정'));
assert_ne!(buf.get(6, 0).symbol.chars().next(), Some('창'));
let _ = UnicodeWidthStr::width(""); }
#[test]
fn border_title_ascii_unchanged() {
use crate::style::{Align, Border, Constraints, Justify, Margin, Padding};
let mut root = LayoutNode::container(
Direction::Row,
super::tree::ContainerConfig {
gap: 0,
align: Align::Start,
align_self: None,
justify: Justify::Start,
border: Some(Border::Single),
border_sides: BorderSides::all(),
border_style: Style::new(),
bg_color: None,
padding: Padding::default(),
margin: Margin::default(),
constraints: Constraints::default(),
title: Some(("Hello".to_string(), Style::new())),
grow: 0,
},
);
let area = Rect::new(0, 0, 10, 3);
super::flexbox::compute(&mut root, area);
let mut buf = Buffer::empty(area);
render(&root, &mut buf);
let rendered: String = (2..7u32)
.map(|x| buf.get(x, 0).symbol.chars().next().unwrap_or(' '))
.collect();
assert_eq!(rendered, "Hello", "ASCII title should render unchanged");
let right_border = buf.get(9, 0).symbol.chars().next().unwrap_or(' ');
assert_eq!(right_border, '┐', "right border must not be overwritten");
}
#[test]
fn inspector_color_formatting() {
assert_eq!(fmt_color(Color::Rgb(255, 0, 0)), "#ff0000");
assert_eq!(fmt_color(Color::Rgb(18, 52, 86)), "#123456");
assert_eq!(fmt_color(Color::Cyan), "Cyan");
assert_eq!(fmt_color(Color::Reset), "default");
assert_eq!(fmt_color(Color::Indexed(8)), "idx(8)");
assert_eq!(fmt_opt_color(None), "default");
assert_eq!(fmt_opt_color(Some(Color::Red)), "Red");
}
#[test]
fn find_focused_node_walks_overlays() {
let mut root = LayoutNode::container(Direction::Column, default_container_config());
let mut base = LayoutNode::container(Direction::Column, default_container_config());
base.focus_id = Some(0);
root.children.push(base);
let mut overlay_child =
LayoutNode::container(Direction::Column, default_container_config());
overlay_child.focus_id = Some(1);
let mut overlay_root = LayoutNode::container(Direction::Column, default_container_config());
overlay_root.children.push(overlay_child);
root.overlays.push(super::tree::OverlayLayer {
node: overlay_root,
modal: false,
});
let found = find_focused_node(&root, 1).expect("overlay focusable must resolve");
assert_eq!(found.focus_id, Some(1));
assert_eq!(
find_focused_node(&root, 0).and_then(|n| n.focus_id),
Some(0)
);
assert!(find_focused_node(&root, 9).is_none());
}
#[test]
fn inspector_no_focusables_renders_notice() {
let theme = crate::style::Theme::dark();
let names = std::collections::HashMap::new();
let focus = InspectorFocus {
focus_index: 0,
focus_count: 0,
names: &names,
theme: &theme,
};
let root = LayoutNode::container(Direction::Column, default_container_config());
let mut buf = Buffer::empty(Rect::new(0, 0, 60, 10));
render_inspector(&root, &mut buf, &focus);
let mut top = String::new();
for x in 0..60 {
top.push_str(&buf.get(x, 0).symbol);
}
assert!(
top.contains("no focusable widgets"),
"expected no-focusable notice; got {top:?}"
);
}
#[test]
fn inspector_style_panel_shows_focused_widget() {
use crate::style::Padding;
let theme = crate::style::Theme::dark();
let names = std::collections::HashMap::new();
let mut root = LayoutNode::container(Direction::Column, default_container_config());
let mut focused = LayoutNode::container(Direction::Column, default_container_config());
focused.focus_id = Some(0);
focused.pos = (4, 2);
focused.size = (12, 3);
focused.padding = Padding {
top: 1,
right: 2,
bottom: 3,
left: 4,
};
focused.style.fg = Some(Color::Cyan);
focused.bg_color = Some(Color::Rgb(255, 0, 0));
root.children.push(focused);
let focus = InspectorFocus {
focus_index: 0,
focus_count: 1,
names: &names,
theme: &theme,
};
let mut buf = Buffer::empty(Rect::new(0, 0, 120, 10));
render_inspector(&root, &mut buf, &focus);
let mut text = String::new();
for y in 0..10 {
for x in 0..120 {
text.push_str(&buf.get(x, y).symbol);
}
text.push('\n');
}
assert!(
text.contains("focused widget"),
"panel header; got {text:?}"
);
assert!(text.contains("index: 0"), "focus index; got {text:?}");
assert!(text.contains("<unnamed>"), "unnamed marker; got {text:?}");
assert!(text.contains("rect: 4,2 12x3"), "rect line; got {text:?}");
assert!(text.contains("fg: Cyan"), "fg color; got {text:?}");
assert!(text.contains("bg: #ff0000"), "bg color; got {text:?}");
assert!(
text.contains("padding: l4 r2 t1 b3"),
"padding line; got {text:?}"
);
assert!(
text.contains("constraints:"),
"constraints line; got {text:?}"
);
assert!(
text.contains("focus chain (1)"),
"chain header; got {text:?}"
);
}
#[test]
fn inspector_focus_chain_marks_current() {
let theme = crate::style::Theme::dark();
let names = std::collections::HashMap::new();
let mut root = LayoutNode::container(Direction::Column, default_container_config());
for id in 0..3 {
let mut n = LayoutNode::container(Direction::Column, default_container_config());
n.focus_id = Some(id);
n.pos = (0, id as u32);
n.size = (5, 1);
root.children.push(n);
}
let focus = InspectorFocus {
focus_index: 1,
focus_count: 3,
names: &names,
theme: &theme,
};
let mut buf = Buffer::empty(Rect::new(0, 0, 120, 10));
render_inspector(&root, &mut buf, &focus);
let split = 80u32; let mut cursor_indices = Vec::new();
for y in 0..10 {
let mut right = String::new();
for x in split..120 {
right.push_str(&buf.get(x, y).symbol);
}
let trimmed = right.trim();
if trimmed.contains("chain") || trimmed.is_empty() {
continue; }
if let Some(rest) = trimmed.strip_prefix("> ") {
if let Ok(idx) = rest.trim().parse::<usize>() {
cursor_indices.push(idx);
}
}
}
assert_eq!(
cursor_indices,
vec![1],
"exactly index 1 must carry the `>` cursor (not 0/2); got {cursor_indices:?}"
);
}
#[test]
fn inspector_named_focus_in_chain() {
let theme = crate::style::Theme::dark();
let mut names = std::collections::HashMap::new();
names.insert("search".to_string(), 1usize);
let mut root = LayoutNode::container(Direction::Column, default_container_config());
for id in 0..2 {
let mut n = LayoutNode::container(Direction::Column, default_container_config());
n.focus_id = Some(id);
n.size = (5, 1);
root.children.push(n);
}
let focus = InspectorFocus {
focus_index: 1,
focus_count: 2,
names: &names,
theme: &theme,
};
let mut buf = Buffer::empty(Rect::new(0, 0, 120, 10));
render_inspector(&root, &mut buf, &focus);
let mut text = String::new();
for y in 0..10 {
for x in 0..120 {
text.push_str(&buf.get(x, y).symbol);
}
text.push('\n');
}
assert!(
text.contains("search"),
"named focus chain entry must show its name; got {text:?}"
);
assert!(
text.contains("name: search"),
"style panel must show focused widget's name; got {text:?}"
);
}
#[test]
fn inspector_clamps_to_tiny_buffer() {
let theme = crate::style::Theme::dark();
let names = std::collections::HashMap::new();
let mut root = LayoutNode::container(Direction::Column, default_container_config());
let mut n = LayoutNode::container(Direction::Column, default_container_config());
n.focus_id = Some(0);
n.size = (1, 1);
root.children.push(n);
let focus = InspectorFocus {
focus_index: 0,
focus_count: 1,
names: &names,
theme: &theme,
};
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
render_inspector(&root, &mut buf, &focus);
}
}