use crate::widgets::registry::{HitArea, WidgetInstanceState};
use fresh_core::api::{
ButtonKind, HintEntry, OverlayColorSpec, OverlayOptions, TreeNode, WidgetSpec,
};
use fresh_core::text_property::{InlineOverlay, OffsetUnit, TextPropertyEntry};
use serde_json::json;
use std::collections::{HashMap, HashSet};
const KEY_HELP_KEY_FG: &str = "ui.help_key_fg";
const KEY_TOGGLE_ON_FG: &str = "ui.help_key_fg";
const KEY_FOCUSED_FG: &str = "ui.popup_selection_fg";
const KEY_FOCUSED_BG: &str = "ui.popup_selection_bg";
const KEY_DANGER_FG: &str = "diagnostic.error_fg";
const KEY_INPUT_BG: &str = "ui.prompt_bg";
const KEY_TEXT_INPUT_SELECTION_BG: &str = "ui.text_input_selection_bg";
const KEY_PLACEHOLDER_FG: &str = "editor.whitespace_indicator_fg";
const KEY_SECTION_LABEL_FG: &str = "ui.help_key_fg";
const KEY_COMPLETION_DIM_FG: &str = "ui.menu_disabled_fg";
const KEY_COMPLETION_SEL_FG: &str = "ui.popup_selection_fg";
const KEY_COMPLETION_SEL_BG: &str = "ui.popup_selection_bg";
const KEY_COMPLETION_BORDER_FG: &str = "ui.popup_border_fg";
#[derive(Debug, Clone, Copy)]
pub struct FocusCursor {
pub buffer_row: u32,
pub byte_in_row: u32,
}
pub struct RenderOutput {
pub entries: Vec<TextPropertyEntry>,
pub hits: Vec<HitArea>,
pub instance_states: HashMap<String, WidgetInstanceState>,
pub focus_key: String,
pub tabbable: Vec<String>,
pub focus_cursor: Option<FocusCursor>,
pub embeds: Vec<EmbedRect>,
pub overlays: Vec<OverlayRow>,
}
#[derive(Debug, Clone)]
pub struct OverlayRow {
pub buffer_row: u32,
pub entry: TextPropertyEntry,
}
#[derive(Debug, Clone, Copy)]
pub struct EmbedRect {
pub window_id: u32,
pub buffer_row: u32,
pub col_in_row: u32,
pub width_cols: u32,
pub height_rows: u32,
}
pub fn render_spec(
spec: &WidgetSpec,
prev: &HashMap<String, WidgetInstanceState>,
prev_focus_key: &str,
panel_width: u32,
) -> RenderOutput {
let mut tabbable = Vec::new();
collect_tabbable(spec, &mut tabbable);
let focus_key = if !prev_focus_key.is_empty() && tabbable.iter().any(|k| k == prev_focus_key) {
prev_focus_key.to_string()
} else {
tabbable.first().cloned().unwrap_or_default()
};
let mut next_state = HashMap::new();
let (entries, hits, focus_cursor, embeds, overlays) =
render_collected(spec, prev, &mut next_state, &focus_key, panel_width);
RenderOutput {
entries,
hits,
instance_states: next_state,
focus_key,
tabbable,
focus_cursor,
embeds,
overlays,
}
}
fn labeled_section_width_pct(spec: &WidgetSpec) -> Option<u32> {
let WidgetSpec::LabeledSection { width_pct, .. } = spec else {
return None;
};
width_pct.filter(|pct| (1..=100).contains(pct))
}
fn predicts_block(spec: &WidgetSpec) -> bool {
match spec {
WidgetSpec::Col { children, .. } => {
if children.len() > 1 {
return true;
}
children.first().map(predicts_block).unwrap_or(false)
}
WidgetSpec::LabeledSection { .. } => true,
WidgetSpec::Tree { .. } => true,
WidgetSpec::List { .. } => true,
WidgetSpec::Text { rows, .. } => *rows > 1,
WidgetSpec::WindowEmbed { rows, .. } => *rows > 1,
WidgetSpec::Raw { entries, .. } => entries.len() > 1,
WidgetSpec::Row { children, .. } => children.iter().any(predicts_block),
_ => false,
}
}
enum RowPiece {
Inline {
entry: TextPropertyEntry,
hits: Vec<HitArea>,
focus_cursor: Option<FocusCursor>,
embeds: Vec<EmbedRect>,
},
Block {
column_width: u32,
entries: Vec<TextPropertyEntry>,
hits: Vec<HitArea>,
focus_cursor: Option<FocusCursor>,
embeds: Vec<EmbedRect>,
},
Flex,
}
fn strip_trailing_newline(entry: &mut TextPropertyEntry) {
if entry.text.ends_with('\n') {
entry.text.pop();
}
}
fn ensure_trailing_newline(entry: &mut TextPropertyEntry) {
if !entry.text.ends_with('\n') {
entry.text.push('\n');
}
}
fn collect_tabbable(spec: &WidgetSpec, out: &mut Vec<String>) {
match spec {
WidgetSpec::Button {
key: Some(k),
disabled,
..
} if !k.is_empty() && !*disabled => {
out.push(k.clone());
}
WidgetSpec::Toggle { key: Some(k), .. }
| WidgetSpec::Text { key: Some(k), .. }
| WidgetSpec::Tree { key: Some(k), .. }
if !k.is_empty() =>
{
out.push(k.clone());
}
WidgetSpec::List {
key: Some(k),
focusable,
..
} if !k.is_empty() && *focusable => {
out.push(k.clone());
}
_ => {}
}
for c in spec.children() {
collect_tabbable(c, out);
}
}
fn render_collected(
spec: &WidgetSpec,
prev: &HashMap<String, WidgetInstanceState>,
next_state: &mut HashMap<String, WidgetInstanceState>,
focus_key: &str,
panel_width: u32,
) -> (
Vec<TextPropertyEntry>,
Vec<HitArea>,
Option<FocusCursor>,
Vec<EmbedRect>,
Vec<OverlayRow>,
) {
let mut entries: Vec<TextPropertyEntry> = Vec::new();
let mut hits: Vec<HitArea> = Vec::new();
let mut focus_cursor: Option<FocusCursor> = None;
let mut embeds: Vec<EmbedRect> = Vec::new();
let mut overlays: Vec<OverlayRow> = Vec::new();
match spec {
WidgetSpec::Row { children, .. } => {
let block_indices: Vec<usize> = children
.iter()
.enumerate()
.filter(|(_, c)| predicts_block(c))
.map(|(i, _)| i)
.collect();
let block_count = block_indices.len();
let mut per_child_width: Vec<u32> = children.iter().map(|_| panel_width).collect();
if block_count > 0 {
let mut explicit_total: u32 = 0;
let mut explicit_count: u32 = 0;
for &idx in &block_indices {
if let Some(pct) = labeled_section_width_pct(&children[idx]) {
let w = (panel_width as u64 * pct as u64 / 100) as u32;
per_child_width[idx] = w.max(1);
explicit_total = explicit_total.saturating_add(w);
explicit_count += 1;
}
}
let remaining = panel_width.saturating_sub(explicit_total);
let implicit_count = (block_count as u32).saturating_sub(explicit_count).max(1);
let each_implicit = (remaining / implicit_count).max(1);
for &idx in &block_indices {
if labeled_section_width_pct(&children[idx]).is_none() {
per_child_width[idx] = each_implicit;
}
}
}
let mut row_pieces: Vec<RowPiece> = Vec::new();
for (idx, child) in children.iter().enumerate() {
if let WidgetSpec::Spacer { flex: true, .. } = child {
row_pieces.push(RowPiece::Flex);
continue;
}
let child_panel_width = per_child_width[idx];
let (child_entries, child_hits, child_focus, child_embeds, child_overlays) =
render_collected(child, prev, next_state, focus_key, child_panel_width);
overlays.extend(child_overlays);
if child_entries.is_empty() {
debug_assert!(child_hits.is_empty(), "empty children produce no hits");
continue;
}
if child_entries.len() == 1 {
let mut entry = child_entries.into_iter().next().unwrap();
strip_trailing_newline(&mut entry);
row_pieces.push(RowPiece::Inline {
entry,
hits: child_hits,
focus_cursor: child_focus,
embeds: child_embeds,
});
} else {
row_pieces.push(RowPiece::Block {
column_width: child_panel_width,
entries: child_entries,
hits: child_hits,
focus_cursor: child_focus,
embeds: child_embeds,
});
}
}
let has_blocks = row_pieces
.iter()
.any(|p| matches!(p, RowPiece::Block { .. }));
if has_blocks {
zip_row_blocks(
row_pieces,
panel_width,
&mut entries,
&mut hits,
&mut focus_cursor,
&mut embeds,
);
} else {
let inline_natural: usize = row_pieces
.iter()
.filter_map(|p| match p {
RowPiece::Inline { entry, .. } => Some(entry.text.len()),
_ => None,
})
.sum();
let flex_count = row_pieces
.iter()
.filter(|p| matches!(p, RowPiece::Flex))
.count();
let flex_total = (panel_width as usize).saturating_sub(inline_natural);
let (flex_each, flex_extra) = match flex_total.checked_div(flex_count) {
Some(each) => (each, flex_total % flex_count),
None => (0, 0),
};
let mut acc: Option<TextPropertyEntry> = None;
let mut flex_seen = 0usize;
for piece in row_pieces {
match piece {
RowPiece::Inline {
mut entry,
hits: child_hits,
focus_cursor: child_focus,
embeds: child_embeds,
} => {
let inline_shift = match acc.as_ref() {
Some(e) => e.text.len(),
None => 0,
};
for mut h in child_hits {
h.byte_start += inline_shift;
h.byte_end += inline_shift;
hits.push(h);
}
if let Some(mut fc) = child_focus {
fc.byte_in_row += inline_shift as u32;
focus_cursor = Some(fc);
}
for mut emb in child_embeds {
emb.col_in_row += inline_shift as u32;
embeds.push(emb);
}
match acc.as_mut() {
Some(merged) => merge_inline(merged, &mut entry),
None => acc = Some(entry),
}
}
RowPiece::Flex => {
let n = flex_each + if flex_seen < flex_extra { 1 } else { 0 };
flex_seen += 1;
if n > 0 {
let mut text = String::with_capacity(n);
for _ in 0..n {
text.push(' ');
}
let entry = TextPropertyEntry {
text,
properties: Default::default(),
style: None,
inline_overlays: Vec::new(),
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
};
match acc.as_mut() {
Some(merged) => {
let mut e = entry;
merge_inline(merged, &mut e);
}
None => acc = Some(entry),
}
}
}
RowPiece::Block { .. } => {
debug_assert!(false, "block piece in inline-only Row path");
}
}
}
if let Some(mut merged) = acc {
ensure_trailing_newline(&mut merged);
entries.push(merged);
}
}
}
WidgetSpec::Col { children, .. } => {
for child in children {
let is_overlay = matches!(child, WidgetSpec::Overlay { .. });
let (child_entries, child_hits, child_focus, child_embeds, child_overlays) =
render_collected(child, prev, next_state, focus_key, panel_width);
let row_offset = entries.len() as u32;
if is_overlay {
for (i, e) in child_entries.into_iter().enumerate() {
overlays.push(OverlayRow {
buffer_row: row_offset + i as u32,
entry: e,
});
}
for mut h in child_hits {
h.buffer_row += row_offset;
hits.push(h);
}
if let Some(mut fc) = child_focus {
fc.buffer_row += row_offset;
focus_cursor = Some(fc);
}
overlays.extend(child_overlays);
for mut emb in child_embeds {
emb.buffer_row += row_offset;
embeds.push(emb);
}
continue;
}
for mut h in child_hits {
h.buffer_row += row_offset;
hits.push(h);
}
if let Some(mut fc) = child_focus {
fc.buffer_row += row_offset;
focus_cursor = Some(fc);
}
for mut emb in child_embeds {
emb.buffer_row += row_offset;
embeds.push(emb);
}
overlays.extend(child_overlays.into_iter().map(|mut o| {
o.buffer_row += row_offset;
o
}));
entries.extend(child_entries);
}
}
WidgetSpec::HintBar {
entries: hint_entries,
..
} => {
let mut entry = render_hint_bar(hint_entries);
ensure_trailing_newline(&mut entry);
entries.push(entry);
}
WidgetSpec::Toggle {
checked,
label,
focused,
key,
} => {
let is_focused = match key.as_deref() {
Some(k) if !k.is_empty() => k == focus_key,
_ => *focused,
};
let mut entry = render_toggle(*checked, label, is_focused);
let byte_end = entry.text.len();
hits.push(HitArea {
widget_key: key.clone().unwrap_or_default(),
widget_kind: "toggle",
buffer_row: 0,
byte_start: 0,
byte_end,
payload: json!({ "checked": !*checked }),
event_type: "toggle",
});
ensure_trailing_newline(&mut entry);
entries.push(entry);
}
WidgetSpec::Button {
label,
focused,
intent,
key,
disabled,
} => {
let is_focused = match key.as_deref() {
Some(k) if !k.is_empty() && !*disabled => k == focus_key,
_ => !*disabled && *focused,
};
let mut entry = render_button(label, is_focused, *intent, *disabled);
if !*disabled {
let byte_end = entry.text.len();
hits.push(HitArea {
widget_key: key.clone().unwrap_or_default(),
widget_kind: "button",
buffer_row: 0,
byte_start: 0,
byte_end,
payload: json!({}),
event_type: "activate",
});
}
ensure_trailing_newline(&mut entry);
entries.push(entry);
}
WidgetSpec::Spacer { cols, flex, .. } => {
let _ = flex;
let cols = (*cols).min(4096) as usize;
let mut text = String::with_capacity(cols + 1);
for _ in 0..cols {
text.push(' ');
}
let mut entry = TextPropertyEntry {
text,
properties: Default::default(),
style: None,
inline_overlays: Vec::new(),
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
};
ensure_trailing_newline(&mut entry);
entries.push(entry);
}
WidgetSpec::List {
items,
item_keys,
selected_index,
visible_rows,
focusable: _,
key: list_key,
} => {
let total = items.len() as u32;
let visible = (*visible_rows).max(1);
let (prev_scroll, prev_sel) = list_key
.as_deref()
.and_then(|k| prev.get(k))
.and_then(|s| match s {
WidgetInstanceState::List {
scroll_offset,
selected_index,
} => Some((*scroll_offset, *selected_index)),
_ => None,
})
.unwrap_or((0, *selected_index));
let effective_sel = if prev_sel < 0 || total == 0 {
-1
} else if (prev_sel as u32) >= total {
(total - 1) as i32
} else {
prev_sel
};
let mut scroll = prev_scroll;
if effective_sel >= 0 {
let sel = effective_sel as u32;
if sel < scroll {
scroll = sel;
}
if sel >= scroll + visible {
scroll = sel + 1 - visible;
}
}
let max_scroll = total.saturating_sub(visible);
if scroll > max_scroll {
scroll = max_scroll;
}
if let Some(k) = list_key.as_deref() {
next_state.insert(
k.to_string(),
WidgetInstanceState::List {
scroll_offset: scroll,
selected_index: effective_sel,
},
);
}
let start = scroll as usize;
let end = ((scroll + visible) as usize).min(items.len());
for (offset, item) in items[start..end].iter().enumerate() {
let i = start + offset;
let mut entry = item.clone();
entry.normalize_widths();
let is_selected = i as i32 == effective_sel;
if is_selected {
let mut style = entry.style.unwrap_or_default();
style.bg = Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG));
style.extend_to_line_end = true;
entry.style = Some(style);
}
let byte_end = entry.text.len();
ensure_trailing_newline(&mut entry);
entries.push(entry);
let item_key = item_keys.get(i).cloned().unwrap_or_default();
let hit_row = (entries.len() - 1) as u32;
hits.push(HitArea {
widget_key: item_key.clone(),
widget_kind: "list",
buffer_row: hit_row,
byte_start: 0,
byte_end,
payload: json!({
"index": i as i64,
"key": item_key,
}),
event_type: "select",
});
}
let rendered_items = (end - start) as u32;
for _ in rendered_items..visible {
let mut padding = TextPropertyEntry {
text: String::new(),
properties: Default::default(),
style: None,
inline_overlays: Vec::new(),
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
};
ensure_trailing_newline(&mut padding);
entries.push(padding);
}
}
WidgetSpec::Tree {
nodes,
item_keys,
selected_index,
visible_rows,
expanded_keys,
checkable,
key: tree_key,
} => {
let prev_state = tree_key
.as_deref()
.filter(|k| !k.is_empty())
.and_then(|k| prev.get(k));
let (prev_scroll, prev_sel, prev_expanded) = match prev_state {
Some(WidgetInstanceState::Tree {
scroll_offset,
selected_index,
expanded_keys,
}) => (*scroll_offset, *selected_index, expanded_keys.clone()),
_ => {
let seeded: HashSet<String> = expanded_keys.iter().cloned().collect();
(0, *selected_index, seeded)
}
};
let mut ancestor_open: Vec<bool> = Vec::new();
let mut visible_indices: Vec<usize> = Vec::with_capacity(nodes.len());
for (i, node) in nodes.iter().enumerate() {
let depth = node.depth as usize;
ancestor_open.truncate(depth);
let visible = ancestor_open.iter().all(|open| *open);
if visible {
visible_indices.push(i);
}
let key = item_keys.get(i).cloned().unwrap_or_default();
let is_open = if node.has_children {
!key.is_empty() && prev_expanded.contains(&key)
} else {
true
};
ancestor_open.push(is_open);
}
let total_visible = visible_indices.len() as u32;
let visible = (*visible_rows).max(1);
let clamp_to_visible = |abs: i32| -> i32 {
if abs < 0 || nodes.is_empty() {
return -1;
}
let abs = abs.min((nodes.len() as i32) - 1) as usize;
if let Ok(_pos) = visible_indices.binary_search(&abs) {
return abs as i32;
}
let earlier = visible_indices.iter().rev().find(|&&v| v <= abs);
if let Some(&v) = earlier {
return v as i32;
}
visible_indices.first().map(|&v| v as i32).unwrap_or(-1)
};
let effective_sel_abs = clamp_to_visible(prev_sel);
let sel_visible_pos: i32 = if effective_sel_abs < 0 {
-1
} else {
visible_indices
.iter()
.position(|&v| v == effective_sel_abs as usize)
.map(|p| p as i32)
.unwrap_or(-1)
};
let mut scroll = prev_scroll;
if sel_visible_pos >= 0 {
let sel = sel_visible_pos as u32;
if sel < scroll {
scroll = sel;
}
if sel >= scroll + visible {
scroll = sel + 1 - visible;
}
}
let max_scroll = total_visible.saturating_sub(visible);
if scroll > max_scroll {
scroll = max_scroll;
}
if let Some(k) = tree_key.as_deref().filter(|k| !k.is_empty()) {
next_state.insert(
k.to_string(),
WidgetInstanceState::Tree {
scroll_offset: scroll,
selected_index: effective_sel_abs,
expanded_keys: prev_expanded.clone(),
},
);
}
let start = scroll as usize;
let end = ((scroll + visible) as usize).min(visible_indices.len());
for &abs_idx in &visible_indices[start..end] {
let mut node = nodes[abs_idx].clone();
node.text.normalize_widths();
let item_key = item_keys.get(abs_idx).cloned().unwrap_or_default();
let is_expanded =
node.has_children && !item_key.is_empty() && prev_expanded.contains(&item_key);
let rendered = render_tree_row(&node, is_expanded, *checkable);
let mut entry = rendered.entry;
let is_selected = abs_idx as i32 == effective_sel_abs;
if is_selected {
let mut style = entry.style.unwrap_or_default();
style.bg = Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG));
style.extend_to_line_end = true;
entry.style = Some(style);
}
let row_byte_end = entry.text.len();
ensure_trailing_newline(&mut entry);
entries.push(entry);
let hit_row = (entries.len() - 1) as u32;
let tree_spec_key = tree_key.clone().unwrap_or_default();
if let Some(disc_range) = rendered.disclosure_range {
hits.push(HitArea {
widget_key: tree_spec_key.clone(),
widget_kind: "tree",
buffer_row: hit_row,
byte_start: disc_range.0,
byte_end: disc_range.1,
payload: json!({
"index": abs_idx as i64,
"key": item_key.clone(),
"expanded": !is_expanded,
}),
event_type: "expand",
});
}
if let Some(cb_range) = rendered.checkbox_range {
let new_checked = !nodes[abs_idx].checked.unwrap_or(false);
hits.push(HitArea {
widget_key: tree_spec_key.clone(),
widget_kind: "tree",
buffer_row: hit_row,
byte_start: cb_range.0,
byte_end: cb_range.1,
payload: json!({
"index": abs_idx as i64,
"key": item_key.clone(),
"checked": new_checked,
}),
event_type: "toggle",
});
}
let body_start = match (rendered.checkbox_range, rendered.disclosure_range) {
(Some((_, end)), _) => end + 1, (None, Some((_, end))) => end,
(None, None) => 0,
};
if body_start < row_byte_end {
hits.push(HitArea {
widget_key: tree_spec_key,
widget_kind: "tree",
buffer_row: hit_row,
byte_start: body_start,
byte_end: row_byte_end,
payload: json!({
"index": abs_idx as i64,
"key": item_key,
}),
event_type: "select",
});
}
}
}
WidgetSpec::Text {
value,
cursor_byte,
focused,
label,
placeholder,
rows,
field_width,
max_visible_chars,
full_width,
completions,
completions_visible_rows,
key,
} => {
let _ = completions; let effective_visible_rows = if *completions_visible_rows == 0 {
5u32
} else {
*completions_visible_rows
};
let is_focused = match key.as_deref() {
Some(k) if !k.is_empty() => k == focus_key,
_ => *focused,
};
let multiline_spec = *rows > 1;
let mut effective_editor: crate::primitives::text_edit::TextEdit;
let prev_scroll: u32;
let mut prev_completions: Vec<fresh_core::api::CompletionItem> = Vec::new();
let mut prev_completion_idx: usize = 0;
let mut prev_completion_scroll: u32 = 0;
match key
.as_deref()
.filter(|k| !k.is_empty())
.and_then(|k| prev.get(k))
{
Some(WidgetInstanceState::Text {
editor,
scroll,
completions,
completion_selected_index,
completion_scroll_offset,
}) => {
effective_editor = editor.clone();
prev_scroll = *scroll;
prev_completions = completions.clone();
prev_completion_idx = *completion_selected_index;
prev_completion_scroll = *completion_scroll_offset;
}
_ => {
effective_editor = if multiline_spec {
crate::primitives::text_edit::TextEdit::with_text(value)
} else {
crate::primitives::text_edit::TextEdit::single_line_with_text(value)
};
let seed = if *cursor_byte < 0 {
value.len()
} else {
(*cursor_byte as usize).min(value.len())
};
effective_editor.set_cursor_from_flat(seed);
prev_scroll = 0;
}
}
if !prev_completions.is_empty() {
prev_completion_idx = prev_completion_idx.min(prev_completions.len() - 1);
} else {
prev_completion_idx = 0;
}
let effective_value = effective_editor.value();
let effective_cursor_byte = effective_editor.flat_cursor_byte() as i32;
let effective_cursor = if is_focused {
effective_cursor_byte
} else {
-1
};
let multiline = multiline_spec;
let effective_field_width = if *full_width && !multiline {
let label_overhead = if label.is_empty() {
0u32
} else {
label.chars().count() as u32 + 1
};
panel_width
.saturating_sub(label_overhead)
.saturating_sub(3)
.max(1)
} else {
*field_width
};
let selection_for_render = if is_focused {
effective_editor.selection_flat_range()
} else {
None
};
let new_scroll;
if multiline {
let rendered = render_text_area(
&effective_value,
effective_cursor,
selection_for_render,
is_focused,
label,
placeholder.as_deref(),
*rows,
effective_field_width,
prev_scroll,
panel_width,
);
new_scroll = rendered.scroll_row;
if let (Some(buffer_row), Some(byte_in_row)) =
(rendered.cursor_buffer_row, rendered.cursor_byte_in_row)
{
focus_cursor = Some(FocusCursor {
buffer_row,
byte_in_row: byte_in_row as u32,
});
}
for mut e in rendered.entries {
ensure_trailing_newline(&mut e);
entries.push(e);
}
} else {
let rendered = render_text_input(
&effective_value,
effective_cursor,
selection_for_render,
is_focused,
label,
placeholder.as_deref(),
*max_visible_chars,
effective_field_width,
*full_width,
);
new_scroll = 0;
if let Some(byte_in_row) = rendered.cursor_byte_in_entry {
focus_cursor = Some(FocusCursor {
buffer_row: 0,
byte_in_row: byte_in_row as u32,
});
}
let mut entry = rendered.entry;
ensure_trailing_newline(&mut entry);
entries.push(entry);
}
if !prev_completions.is_empty() {
let popup_inner = panel_width as usize;
let popup_total = popup_inner.saturating_add(4); let total = prev_completions.len() as u32;
let visible = effective_visible_rows.max(1).min(total);
let sel = prev_completion_idx as u32;
let mut scroll = prev_completion_scroll;
if sel >= scroll + visible {
scroll = sel + 1 - visible;
}
let max_scroll = total.saturating_sub(visible);
if scroll > max_scroll {
scroll = max_scroll;
}
prev_completion_scroll = scroll;
let mut anchor: u32 = 1;
overlays.push(OverlayRow {
buffer_row: anchor,
entry: render_completion_dim_separator_overlay(popup_total),
});
anchor += 1;
let needs_scrollbar = total > visible;
let end = (scroll + visible).min(total) as usize;
for (visible_row, i) in (scroll as usize..end).enumerate() {
let item = &prev_completions[i];
let thumb = if needs_scrollbar {
completion_scrollbar_glyph(visible_row as u32, visible, scroll, total)
} else {
None
};
overlays.push(OverlayRow {
buffer_row: anchor,
entry: render_completion_item_overlay(
&item.value,
item.kind.as_deref(),
i == prev_completion_idx,
popup_total,
thumb,
),
});
anchor += 1;
}
overlays.push(OverlayRow {
buffer_row: anchor,
entry: render_completion_bottom_border(popup_total),
});
} else {
prev_completion_scroll = 0;
}
if let Some(k) = key.as_deref().filter(|k| !k.is_empty()) {
next_state.insert(
k.to_string(),
WidgetInstanceState::Text {
editor: effective_editor.clone(),
scroll: new_scroll,
completions: prev_completions,
completion_selected_index: prev_completion_idx,
completion_scroll_offset: prev_completion_scroll,
},
);
}
}
WidgetSpec::LabeledSection { label, child, .. } => {
let inner_width = panel_width.saturating_sub(4).max(1);
let (child_entries, child_hits, child_focus, child_embeds, child_overlays) =
render_collected(child, prev, next_state, focus_key, inner_width);
overlays.extend(child_overlays.into_iter().map(|mut o| {
o.buffer_row += 1;
o
}));
let total_cols = panel_width.max(2) as usize;
entries.push(render_section_top_border(label, total_cols));
for mut child_entry in child_entries {
strip_trailing_newline(&mut child_entry);
let wrapped = wrap_in_side_border(child_entry, inner_width as usize);
let row_offset = entries.len() as u32;
let _ = row_offset;
entries.push(wrapped);
}
let prefix_bytes = LEFT_BORDER_PREFIX.len();
for mut h in child_hits {
h.buffer_row += 1;
h.byte_start += prefix_bytes;
h.byte_end += prefix_bytes;
hits.push(h);
}
if let Some(mut fc) = child_focus {
fc.buffer_row += 1;
fc.byte_in_row += prefix_bytes as u32;
focus_cursor = Some(fc);
}
let prefix_cols = LEFT_BORDER_PREFIX.chars().count() as u32;
for mut emb in child_embeds {
emb.buffer_row += 1;
emb.col_in_row += prefix_cols;
embeds.push(emb);
}
entries.push(render_section_bottom_border(total_cols));
}
WidgetSpec::WindowEmbed {
window_id,
rows: embed_rows,
..
} => {
let cols = panel_width.max(1) as usize;
for _ in 0..*embed_rows {
let mut text = String::with_capacity(cols + 1);
for _ in 0..cols {
text.push(' ');
}
text.push('\n');
entries.push(TextPropertyEntry {
text,
properties: Default::default(),
style: None,
inline_overlays: Vec::new(),
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
});
}
embeds.push(EmbedRect {
window_id: *window_id,
buffer_row: 0,
col_in_row: 0,
width_cols: panel_width,
height_rows: *embed_rows,
});
}
WidgetSpec::Raw {
entries: raw_entries,
..
} => {
for raw_entry in raw_entries {
let mut e = raw_entry.clone();
e.normalize_widths();
ensure_trailing_newline(&mut e);
entries.push(e);
}
}
WidgetSpec::Overlay { child, .. } => {
let (child_entries, child_hits, child_focus, child_embeds, child_overlays) =
render_collected(child, prev, next_state, focus_key, panel_width);
entries.extend(child_entries);
hits.extend(child_hits);
if focus_cursor.is_none() {
focus_cursor = child_focus;
}
embeds.extend(child_embeds);
overlays.extend(child_overlays);
}
}
(entries, hits, focus_cursor, embeds, overlays)
}
const LEFT_BORDER_PREFIX: &str = "│ ";
const RIGHT_BORDER_SUFFIX: &str = " │";
fn render_section_top_border(label: &str, total_cols: usize) -> TextPropertyEntry {
let mut text = String::new();
let mut overlays: Vec<InlineOverlay> = Vec::new();
text.push('╭');
if label.is_empty() {
for _ in 0..total_cols.saturating_sub(2) {
text.push('─');
}
} else {
let label_cols = label.chars().count();
let used = 1 + 1 + 1 + label_cols + 1; text.push('─');
text.push(' ');
let label_byte_start = text.len();
text.push_str(label);
let label_byte_end = text.len();
text.push(' ');
let remaining = total_cols.saturating_sub(used + 1); for _ in 0..remaining {
text.push('─');
}
overlays.push(InlineOverlay {
start: label_byte_start,
end: label_byte_end,
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_SECTION_LABEL_FG)),
bold: true,
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
text.push('╮');
text.push('\n');
TextPropertyEntry {
text,
properties: Default::default(),
style: None,
inline_overlays: overlays,
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
}
}
fn render_section_bottom_border(total_cols: usize) -> TextPropertyEntry {
let mut text = String::new();
text.push('╰');
for _ in 0..total_cols.saturating_sub(2) {
text.push('─');
}
text.push('╯');
text.push('\n');
TextPropertyEntry {
text,
properties: Default::default(),
style: None,
inline_overlays: Vec::new(),
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
}
}
fn render_completion_dim_separator_overlay(total_cols: usize) -> TextPropertyEntry {
let inner = total_cols.saturating_sub(2).max(1);
let mut text = String::with_capacity(total_cols * 4 + 2);
text.push('│');
for _ in 0..inner {
text.push('┄');
}
text.push('│');
text.push('\n');
let left_border_bytes = "│".len();
let dash_bytes = "┄".len() * inner;
let right_border_start = left_border_bytes + dash_bytes;
let right_border_end = right_border_start + "│".len();
let inline_overlays = vec![
InlineOverlay {
start: 0,
end: left_border_bytes,
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
},
InlineOverlay {
start: left_border_bytes,
end: left_border_bytes + dash_bytes,
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_DIM_FG)),
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
},
InlineOverlay {
start: right_border_start,
end: right_border_end,
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
},
];
TextPropertyEntry {
text,
properties: Default::default(),
style: None,
inline_overlays,
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
}
}
fn render_completion_bottom_border(total_cols: usize) -> TextPropertyEntry {
let mut text = String::with_capacity(total_cols * 4 + 2);
text.push('╰');
for _ in 0..total_cols.saturating_sub(2).max(1) {
text.push('─');
}
text.push('╯');
text.push('\n');
TextPropertyEntry {
text,
properties: Default::default(),
style: Some(OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
..Default::default()
}),
inline_overlays: Vec::new(),
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
}
}
fn render_completion_item_overlay(
item: &str,
kind: Option<&str>,
selected: bool,
total_cols: usize,
scrollbar: Option<char>,
) -> TextPropertyEntry {
let inner = total_cols.saturating_sub(2).max(1);
let body_entry = render_completion_item(item, kind, selected, inner, scrollbar);
let mut text = String::with_capacity(body_entry.text.len() + 8);
text.push('│');
let body_no_nl = body_entry.text.trim_end_matches('\n');
text.push_str(body_no_nl);
text.push('│');
text.push('\n');
let left_border_bytes = "│".len();
let body_no_nl_bytes = body_no_nl.len();
let right_border_start = left_border_bytes + body_no_nl_bytes;
let right_border_end = right_border_start + "│".len();
let mut inline_overlays: Vec<InlineOverlay> = Vec::new();
if selected {
inline_overlays.push(InlineOverlay {
start: left_border_bytes,
end: right_border_start,
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_FG)),
bg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_BG)),
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
inline_overlays.extend(body_entry.inline_overlays.into_iter().map(|mut io| {
io.start += left_border_bytes;
io.end += left_border_bytes;
io
}));
inline_overlays.push(InlineOverlay {
start: 0,
end: left_border_bytes,
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
inline_overlays.push(InlineOverlay {
start: right_border_start,
end: right_border_end,
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
TextPropertyEntry {
text,
properties: Default::default(),
style: None,
inline_overlays,
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
}
}
fn render_completion_item(
item: &str,
kind: Option<&str>,
selected: bool,
total_cols: usize,
scrollbar: Option<char>,
) -> TextPropertyEntry {
let text_budget = total_cols.saturating_sub(2).saturating_sub(1);
let item_chars: Vec<char> = item.chars().collect();
let (visible_item, truncated): (String, bool) = if item_chars.len() <= text_budget {
(item.to_string(), false)
} else {
let keep = text_budget.saturating_sub(1);
let head: String = item_chars.iter().take(keep).collect();
(format!("{}…", head), true)
};
let _ = truncated;
let scrollbar_ch = scrollbar.unwrap_or(' ');
let is_history = kind == Some("history");
let history_marker: char = '↶';
let mut text = String::with_capacity(total_cols * 4 + 2);
text.push(' ');
let marker_start_byte = text.len();
if is_history {
text.push(history_marker);
} else {
text.push(' ');
}
let marker_end_byte = text.len();
let item_start_byte = text.len();
text.push_str(&visible_item);
let item_end_byte = text.len();
let used_cols = 2 + visible_item.chars().count();
let pad_cols = total_cols.saturating_sub(used_cols).saturating_sub(1);
for _ in 0..pad_cols {
text.push(' ');
}
text.push(scrollbar_ch);
text.push('\n');
let body_style = if selected {
Some(OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_FG)),
bg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_BG)),
extend_to_line_end: true,
..Default::default()
})
} else {
None
};
let mut inline_overlays: Vec<InlineOverlay> = Vec::new();
if is_history {
inline_overlays.push(InlineOverlay {
start: marker_start_byte,
end: marker_end_byte,
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
inline_overlays.push(InlineOverlay {
start: item_start_byte,
end: item_end_byte,
style: OverlayOptions {
italic: true,
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
if scrollbar.is_some() {
let total_bytes = text.trim_end_matches('\n').len();
let scrollbar_byte_len = scrollbar_ch.len_utf8();
let start = total_bytes - scrollbar_byte_len;
let end = total_bytes;
inline_overlays.push(InlineOverlay {
start,
end,
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_DIM_FG)),
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
TextPropertyEntry {
text,
properties: Default::default(),
style: body_style,
inline_overlays,
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
}
}
fn completion_scrollbar_glyph(
visible_row: u32,
visible: u32,
scroll: u32,
total: u32,
) -> Option<char> {
if total <= visible || visible == 0 {
return None;
}
let thumb_size = ((visible as f32 * visible as f32) / total as f32).round() as u32;
let thumb_size = thumb_size.max(1).min(visible);
let max_scroll = total - visible;
let thumb_top = if max_scroll == 0 {
0
} else {
((scroll as f32 / max_scroll as f32) * (visible - thumb_size) as f32).round() as u32
};
if visible_row >= thumb_top && visible_row < thumb_top + thumb_size {
Some('█')
} else {
None
}
}
fn wrap_in_side_border(mut child: TextPropertyEntry, inner_width: usize) -> TextPropertyEntry {
let prefix_bytes = LEFT_BORDER_PREFIX.len();
let cur_cols = child.text.chars().count();
if cur_cols < inner_width {
for _ in 0..(inner_width - cur_cols) {
child.text.push(' ');
}
} else if cur_cols > inner_width {
let indices: Vec<usize> = child.text.char_indices().map(|(i, _)| i).collect();
let byte_cutoff = indices
.get(inner_width)
.copied()
.unwrap_or(child.text.len());
child.text.truncate(byte_cutoff);
if inner_width >= 2 {
child.text.pop();
child.text.push('…');
}
let byte_cutoff = child.text.len();
child.inline_overlays.retain_mut(|o| {
if o.start >= byte_cutoff {
return false;
}
if o.end > byte_cutoff {
o.end = byte_cutoff;
}
true
});
}
let mut text = String::with_capacity(
LEFT_BORDER_PREFIX.len() + child.text.len() + RIGHT_BORDER_SUFFIX.len() + 1,
);
text.push_str(LEFT_BORDER_PREFIX);
text.push_str(&child.text);
text.push_str(RIGHT_BORDER_SUFFIX);
text.push('\n');
let overlays: Vec<InlineOverlay> = child
.inline_overlays
.into_iter()
.map(|o| InlineOverlay {
start: o.start + prefix_bytes,
end: o.end + prefix_bytes,
style: o.style,
properties: o.properties,
unit: o.unit,
})
.collect();
TextPropertyEntry {
text,
properties: child.properties,
style: child.style,
inline_overlays: overlays,
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
}
}
pub fn render_hint_bar(entries: &[HintEntry]) -> TextPropertyEntry {
let separator = " ";
let mut text = String::new();
let mut overlays = Vec::new();
for (i, entry) in entries.iter().enumerate() {
if i > 0 {
text.push_str(separator);
}
let key_start = text.len();
text.push_str(&entry.keys);
let key_end = text.len();
if key_end > key_start {
overlays.push(InlineOverlay {
start: key_start,
end: key_end,
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
bold: true,
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
if !entry.label.is_empty() {
text.push(' ');
text.push_str(&entry.label);
}
}
TextPropertyEntry {
text,
properties: Default::default(),
style: None,
inline_overlays: overlays,
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
}
}
pub fn render_toggle(checked: bool, label: &str, focused: bool) -> TextPropertyEntry {
let glyph = if checked { "[v]" } else { "[ ]" };
let mut text = String::with_capacity(glyph.len() + 1 + label.len());
text.push_str(glyph);
text.push(' ');
text.push_str(label);
let mut overlays = Vec::new();
if checked {
overlays.push(InlineOverlay {
start: 0,
end: glyph.len(),
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_TOGGLE_ON_FG)),
bold: true,
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
if focused {
overlays.push(InlineOverlay {
start: 0,
end: text.len(),
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
bold: true,
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
TextPropertyEntry {
text,
properties: Default::default(),
style: None,
inline_overlays: overlays,
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
}
}
pub fn render_button(
label: &str,
focused: bool,
kind: ButtonKind,
disabled: bool,
) -> TextPropertyEntry {
let text = format!("[ {} ]", label);
let mut overlays = Vec::new();
let base_style = if disabled {
OverlayOptions {
fg: Some(OverlayColorSpec::theme_key("ui.menu_disabled_fg")),
..Default::default()
}
} else {
match kind {
ButtonKind::Normal => OverlayOptions::default(),
ButtonKind::Primary => OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
bold: true,
..Default::default()
},
ButtonKind::Danger => OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_DANGER_FG)),
bold: true,
..Default::default()
},
}
};
let style = if focused && !disabled {
OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
bold: true,
..base_style
}
} else {
base_style
};
if style.fg.is_some()
|| style.bg.is_some()
|| style.bold
|| style.italic
|| style.underline
|| style.strikethrough
{
overlays.push(InlineOverlay {
start: 0,
end: text.len(),
style,
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
TextPropertyEntry {
text,
properties: Default::default(),
style: None,
inline_overlays: overlays,
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
}
}
pub struct RenderedTreeRow {
pub entry: TextPropertyEntry,
pub disclosure_range: Option<(usize, usize)>,
pub checkbox_range: Option<(usize, usize)>,
}
pub fn render_tree_row(node: &TreeNode, expanded: bool, checkable: bool) -> RenderedTreeRow {
let indent_cols = (node.depth as usize) * 2;
let disclosure_glyph: &str = if node.has_children {
if expanded {
"▼"
} else {
"▶"
}
} else {
" "
};
let separator: &str = if node.has_children { " " } else { "" };
let checkbox_glyph: Option<&'static str> = if checkable {
match node.checked {
Some(true) => Some("[v]"),
Some(false) => Some("[ ]"),
None => None,
}
} else {
None
};
let checkbox_extra = checkbox_glyph.map(|g| g.len() + 1).unwrap_or(0);
let mut text = String::with_capacity(
indent_cols
+ disclosure_glyph.len()
+ separator.len()
+ checkbox_extra
+ node.text.text.len(),
);
for _ in 0..indent_cols {
text.push(' ');
}
let disc_start = text.len();
text.push_str(disclosure_glyph);
let disc_end = text.len();
text.push_str(separator);
let checkbox_range = if let Some(g) = checkbox_glyph {
let cb_start = text.len();
text.push_str(g);
let cb_end = text.len();
text.push(' ');
Some((cb_start, cb_end))
} else {
None
};
let body_start = text.len();
text.push_str(&node.text.text);
let mut overlays: Vec<InlineOverlay> = node
.text
.inline_overlays
.iter()
.map(|o| {
let mut shifted = o.clone();
shifted.start += body_start;
shifted.end += body_start;
shifted
})
.collect();
if node.has_children {
overlays.push(InlineOverlay {
start: disc_start,
end: disc_end,
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
bold: true,
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
if let Some((cb_start, cb_end)) = checkbox_range {
let theme_key = match node.checked {
Some(true) => KEY_TOGGLE_ON_FG,
_ => KEY_PLACEHOLDER_FG,
};
overlays.push(InlineOverlay {
start: cb_start,
end: cb_end,
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(theme_key)),
bold: matches!(node.checked, Some(true)),
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
let disclosure_range = if node.has_children {
Some((disc_start, disc_end))
} else {
None
};
let entry = TextPropertyEntry {
text,
properties: node.text.properties.clone(),
style: node.text.style.clone(),
inline_overlays: overlays,
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
};
RenderedTreeRow {
entry,
disclosure_range,
checkbox_range,
}
}
pub struct RenderedTextInput {
pub entry: TextPropertyEntry,
pub cursor_byte_in_entry: Option<usize>,
}
#[allow(clippy::too_many_arguments)]
pub fn render_text_input(
value: &str,
cursor_byte: i32,
selection: Option<(usize, usize)>,
focused: bool,
label: &str,
placeholder: Option<&str>,
max_visible_chars: u32,
field_width: u32,
full_width: bool,
) -> RenderedTextInput {
let show_placeholder = value.is_empty() && placeholder.is_some();
let raw_cursor_byte = if cursor_byte < 0 {
value.len()
} else {
(cursor_byte as usize).min(value.len())
};
let (inner, cursor_in_inner) = if show_placeholder && field_width == 0 {
let inner = placeholder.unwrap_or("").to_string();
let cursor = if focused { Some(0usize) } else { None };
(inner, cursor)
} else if show_placeholder {
let target = field_width as usize;
let pad_extra = if focused || full_width { 1 } else { 0 };
let total_inner = target + pad_extra;
let raw = placeholder.unwrap_or("");
let raw_chars: Vec<char> = raw.chars().collect();
let inner = if raw_chars.len() <= total_inner {
let mut s = raw.to_string();
while s.chars().count() < total_inner {
s.push(' ');
}
s
} else {
let keep = total_inner.saturating_sub(1);
let prefix: String = raw_chars.iter().take(keep).collect();
format!("{}…", prefix)
};
let cursor = if focused { Some(0usize) } else { None };
(inner, cursor)
} else if field_width > 0 {
let target = field_width as usize;
let pad_extra = if focused || full_width { 1 } else { 0 };
let total_inner = target + pad_extra;
let value_chars: Vec<char> = value.chars().collect();
if value_chars.len() <= target {
let mut padded = value.to_string();
while padded.chars().count() < total_inner {
padded.push(' ');
}
(padded, Some(raw_cursor_byte))
} else {
let keep = target - 1;
let drop_chars = value_chars.len() - keep;
let mut dropped_bytes = 0usize;
for ch in value_chars.iter().take(drop_chars) {
dropped_bytes += ch.len_utf8();
}
let tail = &value[dropped_bytes..];
let mut s = String::with_capacity("…".len() + tail.len() + pad_extra);
s.push('…');
s.push_str(tail);
for _ in 0..pad_extra {
s.push(' ');
}
let cursor_in_inner = if raw_cursor_byte < dropped_bytes {
"…".len()
} else {
"…".len() + (raw_cursor_byte - dropped_bytes)
};
(s, Some(cursor_in_inner))
}
} else if max_visible_chars > 0 && value.chars().count() > max_visible_chars as usize {
let chars: Vec<char> = value.chars().collect();
let take = (max_visible_chars as usize).saturating_sub(1);
let start = chars.len().saturating_sub(take);
let tail: String = chars[start..].iter().collect();
let s = format!("…{}", tail);
(s, Some(raw_cursor_byte.min(value.len())))
} else {
let mut s = value.to_string();
if focused {
s.push(' ');
}
(s, Some(raw_cursor_byte))
};
let mut text = String::new();
if !label.is_empty() {
text.push_str(label);
text.push(' ');
}
let bracket_open_byte = text.len();
text.push('[');
let inner_byte_start = text.len();
text.push_str(&inner);
let inner_byte_end = text.len();
text.push(']');
let bracket_close_byte = text.len();
let mut overlays = Vec::new();
if show_placeholder {
overlays.push(InlineOverlay {
start: inner_byte_start,
end: inner_byte_end,
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
italic: true,
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
if focused {
overlays.push(InlineOverlay {
start: bracket_open_byte,
end: bracket_close_byte,
style: OverlayOptions {
bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
let inner_is_truncated = inner.starts_with('…');
if focused && !inner_is_truncated {
if let Some((sel_start, sel_end)) = selection {
let visible_value_len = value.len();
let s = sel_start.min(sel_end).min(visible_value_len);
let e = sel_start.max(sel_end).min(visible_value_len);
if e > s {
overlays.push(InlineOverlay {
start: inner_byte_start + s,
end: inner_byte_start + e,
style: OverlayOptions {
bg: Some(OverlayColorSpec::theme_key(KEY_TEXT_INPUT_SELECTION_BG)),
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
}
}
let cursor_byte_in_entry = if focused {
cursor_in_inner.map(|c| inner_byte_start + c)
} else {
None
};
RenderedTextInput {
entry: TextPropertyEntry {
text,
properties: Default::default(),
style: None,
inline_overlays: overlays,
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
},
cursor_byte_in_entry,
}
}
pub struct RenderedTextArea {
pub entries: Vec<TextPropertyEntry>,
pub scroll_row: u32,
pub cursor_buffer_row: Option<u32>,
pub cursor_byte_in_row: Option<usize>,
}
#[allow(clippy::too_many_arguments)]
pub fn render_text_area(
value: &str,
cursor_byte: i32,
selection: Option<(usize, usize)>,
focused: bool,
label: &str,
placeholder: Option<&str>,
visible_rows: u32,
field_width: u32,
prev_scroll: u32,
panel_width: u32,
) -> RenderedTextArea {
let target_width: usize = if field_width > 0 {
field_width as usize
} else if panel_width != u32::MAX && panel_width > 0 {
panel_width as usize
} else {
40
};
let mut lines: Vec<&str> = value.split('\n').collect();
if lines.is_empty() {
lines.push("");
}
let raw_cursor_byte = if cursor_byte < 0 {
value.len()
} else {
(cursor_byte as usize).min(value.len())
};
let (cursor_line, cursor_col) = byte_to_line_col(value, raw_cursor_byte);
let selection_lc: Option<((usize, usize), (usize, usize))> = selection.and_then(|(a, b)| {
let lo = a.min(b);
let hi = a.max(b);
if hi <= lo || hi > value.len() {
return None;
}
Some((byte_to_line_col(value, lo), byte_to_line_col(value, hi)))
});
let visible_rows_usize = visible_rows.max(1) as usize;
let mut scroll_row = prev_scroll as usize;
if cursor_line < scroll_row {
scroll_row = cursor_line;
} else if cursor_line >= scroll_row + visible_rows_usize {
scroll_row = cursor_line + 1 - visible_rows_usize;
}
let max_scroll = lines.len().saturating_sub(visible_rows_usize);
if scroll_row > max_scroll {
scroll_row = max_scroll;
}
let show_placeholder =
!focused && value.is_empty() && placeholder.is_some() && !placeholder.unwrap().is_empty();
let mut entries: Vec<TextPropertyEntry> = Vec::new();
let mut cursor_buffer_row: Option<u32> = None;
let mut cursor_byte_in_row: Option<usize> = None;
if !label.is_empty() {
let mut text = String::with_capacity(label.len() + 2);
text.push_str(label);
text.push(':');
entries.push(TextPropertyEntry {
text,
properties: Default::default(),
style: None,
inline_overlays: Vec::new(),
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
});
}
let label_offset: u32 = entries.len() as u32;
for row_in_view in 0..visible_rows_usize {
let line_idx = scroll_row + row_in_view;
let mut row_text;
let mut overlays: Vec<InlineOverlay> = Vec::new();
if line_idx < lines.len() {
row_text = pad_or_truncate_line(lines[line_idx], target_width);
} else {
row_text = " ".repeat(target_width);
}
if show_placeholder && row_in_view == 0 {
let ph = placeholder.unwrap();
row_text = pad_or_truncate_line(ph, target_width);
overlays.push(InlineOverlay {
start: 0,
end: row_text.len(),
style: OverlayOptions {
fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
if focused {
overlays.push(InlineOverlay {
start: 0,
end: row_text.len(),
style: OverlayOptions {
bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
if focused {
if let Some(((sl, sc), (el, ec))) = selection_lc {
if line_idx >= sl && line_idx <= el {
let line_text_len = if line_idx < lines.len() {
lines[line_idx].len()
} else {
0
};
let row_start = if line_idx == sl { sc } else { 0 };
let row_end = if line_idx == el { ec } else { line_text_len };
let s = row_start.min(line_text_len);
let e = row_end.min(line_text_len);
if e > s {
overlays.push(InlineOverlay {
start: s,
end: e,
style: OverlayOptions {
bg: Some(OverlayColorSpec::theme_key(KEY_TEXT_INPUT_SELECTION_BG)),
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
}
}
}
if focused && line_idx == cursor_line && cursor_byte >= 0 {
let col_in_line = cursor_col.min(row_text.len());
cursor_buffer_row = Some(label_offset + row_in_view as u32);
cursor_byte_in_row = Some(col_in_line);
}
entries.push(TextPropertyEntry {
text: row_text,
properties: Default::default(),
style: None,
inline_overlays: overlays,
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
});
}
RenderedTextArea {
entries,
scroll_row: scroll_row as u32,
cursor_buffer_row,
cursor_byte_in_row,
}
}
fn byte_to_line_col(value: &str, byte: usize) -> (usize, usize) {
let byte = byte.min(value.len());
let mut line = 0usize;
let mut line_start = 0usize;
for (i, &b) in value.as_bytes().iter().enumerate().take(byte) {
if b == b'\n' {
line += 1;
line_start = i + 1;
}
}
(line, byte - line_start)
}
fn pad_or_truncate_line(line: &str, target: usize) -> String {
let chars: Vec<char> = line.chars().collect();
if chars.len() <= target {
let mut out = line.to_string();
let pad = target - chars.len();
for _ in 0..pad {
out.push(' ');
}
out
} else {
let keep = target.saturating_sub(1);
let mut out: String = chars.iter().take(keep).collect();
out.push('…');
out
}
}
fn merge_inline(merged: &mut TextPropertyEntry, next: &mut TextPropertyEntry) {
let shift = merged.text.len();
merged.text.push_str(&next.text);
for overlay in next.inline_overlays.drain(..) {
merged.inline_overlays.push(InlineOverlay {
start: overlay.start + shift,
end: overlay.end + shift,
style: overlay.style,
properties: overlay.properties,
unit: overlay.unit,
});
}
}
fn pad_or_truncate_cols(text: &mut String, cols: usize) {
let cur = text.chars().count();
if cur < cols {
for _ in 0..(cols - cur) {
text.push(' ');
}
} else if cur > cols {
let cutoff = text
.char_indices()
.nth(cols)
.map(|(i, _)| i)
.unwrap_or(text.len());
text.truncate(cutoff);
if cols >= 2 {
text.pop();
text.push('…');
}
}
}
fn zip_row_blocks(
pieces: Vec<RowPiece>,
panel_width: u32,
out_entries: &mut Vec<TextPropertyEntry>,
out_hits: &mut Vec<HitArea>,
out_focus_cursor: &mut Option<FocusCursor>,
out_embeds: &mut Vec<EmbedRect>,
) {
let starting_row = out_entries.len() as u32;
let _ = panel_width;
let max_height = pieces
.iter()
.filter_map(|p| match p {
RowPiece::Block { entries, .. } => Some(entries.len()),
_ => None,
})
.max()
.unwrap_or(0);
if max_height == 0 {
return;
}
for row_idx in 0..max_height {
let mut text = String::new();
let mut overlays: Vec<InlineOverlay> = Vec::new();
for piece in &pieces {
match piece {
RowPiece::Inline {
entry,
hits,
focus_cursor,
embeds: inline_embeds,
} => {
let inline_cols = entry.text.chars().count();
let byte_shift = text.len();
let col_shift = text.chars().count() as u32;
if row_idx == 0 {
text.push_str(&entry.text);
for emb in inline_embeds {
out_embeds.push(EmbedRect {
window_id: emb.window_id,
buffer_row: starting_row + emb.buffer_row,
col_in_row: emb.col_in_row + col_shift,
width_cols: emb.width_cols,
height_rows: emb.height_rows,
});
}
for overlay in &entry.inline_overlays {
overlays.push(InlineOverlay {
start: overlay.start + byte_shift,
end: overlay.end + byte_shift,
style: overlay.style.clone(),
properties: overlay.properties.clone(),
unit: overlay.unit,
});
}
for h in hits {
let mut h = h.clone();
h.byte_start += byte_shift;
h.byte_end += byte_shift;
h.buffer_row = starting_row;
out_hits.push(h);
}
if let Some(fc) = focus_cursor {
*out_focus_cursor = Some(FocusCursor {
buffer_row: starting_row,
byte_in_row: fc.byte_in_row + byte_shift as u32,
});
}
} else {
for _ in 0..inline_cols {
text.push(' ');
}
}
}
RowPiece::Flex => {
}
RowPiece::Block {
column_width,
entries,
hits,
focus_cursor,
embeds: block_embeds,
} => {
let block_w = *column_width as usize;
let byte_shift = text.len();
let col_shift = text.chars().count() as u32;
if row_idx == 0 {
for emb in block_embeds {
out_embeds.push(EmbedRect {
window_id: emb.window_id,
buffer_row: starting_row + emb.buffer_row,
col_in_row: emb.col_in_row + col_shift,
width_cols: emb.width_cols,
height_rows: emb.height_rows,
});
}
}
if let Some(line) = entries.get(row_idx) {
let mut line_text = line.text.clone();
if line_text.ends_with('\n') {
line_text.pop();
}
let original_byte_len = line_text.len();
pad_or_truncate_cols(&mut line_text, block_w);
let padded_byte_len = line_text.len();
text.push_str(&line_text);
if let Some(line_style) = &line.style {
overlays.push(InlineOverlay {
start: byte_shift,
end: byte_shift + padded_byte_len,
style: line_style.clone(),
properties: Default::default(),
unit: OffsetUnit::Byte,
});
}
for overlay in &line.inline_overlays {
let new_end = overlay.end.min(original_byte_len);
if overlay.start >= original_byte_len {
continue;
}
overlays.push(InlineOverlay {
start: overlay.start + byte_shift,
end: new_end + byte_shift,
style: overlay.style.clone(),
properties: overlay.properties.clone(),
unit: overlay.unit,
});
}
for h in hits {
if h.buffer_row != row_idx as u32 {
continue;
}
let mut h = h.clone();
h.byte_start += byte_shift;
h.byte_end += byte_shift;
h.buffer_row = starting_row + row_idx as u32;
out_hits.push(h);
}
if let Some(fc) = focus_cursor {
if fc.buffer_row == row_idx as u32 {
*out_focus_cursor = Some(FocusCursor {
buffer_row: starting_row + row_idx as u32,
byte_in_row: fc.byte_in_row + byte_shift as u32,
});
}
}
} else {
for _ in 0..block_w {
text.push(' ');
}
}
}
}
}
text.push('\n');
out_entries.push(TextPropertyEntry {
text,
properties: Default::default(),
style: None,
inline_overlays: overlays,
segments: Vec::new(),
pad_to_chars: None,
truncate_to_chars: None,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render_no_focus(
spec: &WidgetSpec,
prev: &HashMap<String, WidgetInstanceState>,
) -> (
Vec<TextPropertyEntry>,
Vec<HitArea>,
HashMap<String, WidgetInstanceState>,
) {
let out = render_spec(spec, prev, "", u32::MAX);
(out.entries, out.hits, out.instance_states)
}
#[test]
fn hint_bar_renders_entries_with_key_overlays() {
let entries = vec![
HintEntry {
keys: "Tab".into(),
label: "next".into(),
},
HintEntry {
keys: "Esc".into(),
label: "close".into(),
},
];
let entry = render_hint_bar(&entries);
assert_eq!(entry.text, "Tab next Esc close");
assert_eq!(entry.inline_overlays.len(), 2);
assert_eq!(entry.inline_overlays[0].start, 0);
assert_eq!(entry.inline_overlays[0].end, 3);
assert_eq!(entry.inline_overlays[1].start, 10);
assert_eq!(entry.inline_overlays[1].end, 13);
}
#[test]
fn hint_bar_omits_label_when_empty() {
let entries = vec![HintEntry {
keys: "?".into(),
label: "".into(),
}];
let entry = render_hint_bar(&entries);
assert_eq!(entry.text, "?");
}
#[test]
fn col_stacks_children_top_to_bottom() {
let spec = WidgetSpec::Col {
children: vec![
WidgetSpec::HintBar {
entries: vec![HintEntry {
keys: "A".into(),
label: "alpha".into(),
}],
key: None,
},
WidgetSpec::HintBar {
entries: vec![HintEntry {
keys: "B".into(),
label: "beta".into(),
}],
key: None,
},
],
key: None,
};
let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(out.len(), 2);
assert_eq!(out[0].text, "A alpha\n");
assert_eq!(out[1].text, "B beta\n");
assert!(hits.is_empty(), "HintBar emits no hit areas in v1");
}
#[test]
fn raw_passes_through_unchanged() {
let spec = WidgetSpec::Raw {
entries: vec![TextPropertyEntry::text("hello")],
key: None,
};
let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(out.len(), 1);
assert_eq!(out[0].text, "hello\n");
assert!(hits.is_empty());
}
#[test]
fn toggle_checked_emits_glyph_overlay() {
let entry = render_toggle(true, "Case", false);
assert_eq!(entry.text, "[v] Case");
assert_eq!(entry.inline_overlays.len(), 1);
assert_eq!(entry.inline_overlays[0].start, 0);
assert_eq!(entry.inline_overlays[0].end, 3);
}
#[test]
fn toggle_unchecked_no_glyph_overlay() {
let entry = render_toggle(false, "Case", false);
assert_eq!(entry.text, "[ ] Case");
assert_eq!(entry.inline_overlays.len(), 0);
}
#[test]
fn toggle_focused_adds_full_entry_overlay() {
let entry = render_toggle(true, "Case", true);
assert_eq!(entry.inline_overlays.len(), 2);
assert_eq!(entry.inline_overlays[1].start, 0);
assert_eq!(entry.inline_overlays[1].end, entry.text.len());
assert!(entry.inline_overlays[1].style.bold);
}
#[test]
fn button_normal_unfocused_has_no_overlay() {
let entry = render_button("Replace All", false, ButtonKind::Normal, false);
assert_eq!(entry.text, "[ Replace All ]");
assert!(entry.inline_overlays.is_empty());
}
#[test]
fn button_primary_unfocused_is_bold_help_key_fg_with_no_bg() {
let entry = render_button("Submit", false, ButtonKind::Primary, false);
assert_eq!(entry.inline_overlays.len(), 1);
let style = &entry.inline_overlays[0].style;
assert!(style.bold);
assert_eq!(
style.fg.as_ref().and_then(|c| c.as_theme_key()),
Some("ui.help_key_fg"),
);
assert!(style.bg.is_none(), "unfocused primary must not paint a bg");
}
#[test]
fn button_danger_uses_error_theme_key() {
let entry = render_button("Delete", false, ButtonKind::Danger, false);
assert_eq!(entry.inline_overlays.len(), 1);
let fg = entry.inline_overlays[0].style.fg.as_ref().unwrap();
assert_eq!(fg.as_theme_key(), Some("diagnostic.error_fg"));
assert!(entry.inline_overlays[0].style.bold);
}
#[test]
fn button_focused_overrides_with_popup_selection_keys() {
let entry = render_button("OK", true, ButtonKind::Normal, false);
let style = &entry.inline_overlays[0].style;
assert_eq!(
style.fg.as_ref().and_then(|c| c.as_theme_key()),
Some("ui.popup_selection_fg")
);
assert_eq!(
style.bg.as_ref().and_then(|c| c.as_theme_key()),
Some("ui.popup_selection_bg")
);
assert!(style.bold);
}
#[test]
fn flex_spacer_fills_remaining_row_width() {
let spec = WidgetSpec::Row {
children: vec![
WidgetSpec::Toggle {
checked: false,
label: "A".into(),
focused: false,
key: None,
},
WidgetSpec::Spacer {
cols: 0,
flex: true,
key: None,
},
WidgetSpec::Button {
label: "B".into(),
focused: false,
intent: ButtonKind::Normal,
key: None,
disabled: false,
},
],
key: None,
};
let out = render_spec(&spec, &HashMap::new(), "", 30);
assert_eq!(out.entries.len(), 1);
let text = &out.entries[0].text;
assert_eq!(text.len(), 31);
assert!(text.starts_with("[ ] A"));
assert!(text.ends_with("[ B ]\n"));
let button_hit = out.hits.iter().find(|h| h.widget_kind == "button").unwrap();
assert_eq!(button_hit.byte_start, 25);
assert_eq!(button_hit.byte_end, 30);
}
#[test]
fn flex_spacer_with_no_leftover_collapses_to_zero() {
let spec = WidgetSpec::Row {
children: vec![
WidgetSpec::Toggle {
checked: false,
label: "A".into(),
focused: false,
key: None,
},
WidgetSpec::Spacer {
cols: 0,
flex: true,
key: None,
},
WidgetSpec::Toggle {
checked: false,
label: "B".into(),
focused: false,
key: None,
},
],
key: None,
};
let out = render_spec(&spec, &HashMap::new(), "", 10);
assert_eq!(out.entries[0].text, "[ ] A[ ] B\n");
}
#[test]
fn spacer_in_row_pads_with_spaces() {
let spec = WidgetSpec::Row {
children: vec![
WidgetSpec::Toggle {
checked: false,
label: "A".into(),
focused: false,
key: None,
},
WidgetSpec::Spacer {
cols: 4,
flex: false,
key: None,
},
WidgetSpec::Button {
label: "Go".into(),
focused: false,
intent: ButtonKind::Normal,
key: None,
disabled: false,
},
],
key: None,
};
let (out, _hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(out.len(), 1);
assert_eq!(out[0].text, "[ ] A [ Go ]\n");
}
#[test]
fn row_collapses_inline_children_with_shifted_overlays() {
let spec = WidgetSpec::Row {
children: vec![
WidgetSpec::HintBar {
entries: vec![HintEntry {
keys: "Tab".into(),
label: "x".into(),
}],
key: None,
},
WidgetSpec::HintBar {
entries: vec![HintEntry {
keys: "Esc".into(),
label: "y".into(),
}],
key: None,
},
],
key: None,
};
let (out, _hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(out.len(), 1);
assert_eq!(out[0].text, "Tab xEsc y\n");
assert_eq!(out[0].inline_overlays.len(), 2);
assert_eq!(out[0].inline_overlays[1].start, 5);
assert_eq!(out[0].inline_overlays[1].end, 8);
}
#[test]
fn toggle_emits_hit_area_with_toggle_payload() {
let spec = WidgetSpec::Toggle {
checked: false,
label: "Case".into(),
focused: false,
key: Some("case".into()),
};
let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(hits.len(), 1);
let h = &hits[0];
assert_eq!(h.widget_key, "case");
assert_eq!(h.widget_kind, "toggle");
assert_eq!(h.event_type, "toggle");
assert_eq!(h.buffer_row, 0);
assert_eq!(h.byte_start, 0);
assert_eq!(h.byte_end, "[ ] Case".len());
assert_eq!(h.payload, json!({"checked": true}));
}
#[test]
fn button_emits_hit_area_with_activate_payload() {
let spec = WidgetSpec::Button {
label: "Replace All".into(),
focused: false,
intent: ButtonKind::Primary,
key: Some("replace".into()),
disabled: false,
};
let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(hits.len(), 1);
let h = &hits[0];
assert_eq!(h.widget_key, "replace");
assert_eq!(h.widget_kind, "button");
assert_eq!(h.event_type, "activate");
assert_eq!(h.byte_end, "[ Replace All ]".len());
assert_eq!(h.payload, json!({}));
}
#[test]
fn disabled_button_omits_hit_area_and_skips_tabbable() {
let spec = WidgetSpec::Row {
children: vec![
WidgetSpec::Button {
label: "Archive".into(),
focused: false,
intent: ButtonKind::Normal,
key: Some("archive".into()),
disabled: true,
},
WidgetSpec::Button {
label: "Cancel".into(),
focused: false,
intent: ButtonKind::Normal,
key: Some("cancel".into()),
disabled: false,
},
],
key: None,
};
let out = render_spec(&spec, &HashMap::new(), "", 30);
assert_eq!(
out.hits
.iter()
.filter(|h| h.widget_kind == "button")
.count(),
1,
"disabled button should not emit a hit area"
);
assert_eq!(
out.tabbable,
vec!["cancel".to_string()],
"disabled button must drop out of the Tab cycle"
);
}
#[test]
fn disabled_button_uses_menu_disabled_fg_overlay() {
let entry = render_button("Archive", false, ButtonKind::Danger, true);
assert_eq!(entry.inline_overlays.len(), 1);
let style = &entry.inline_overlays[0].style;
assert_eq!(
style.fg.as_ref().and_then(|c| c.as_theme_key()),
Some("ui.menu_disabled_fg"),
"disabled overrides Danger fg with the muted theme key"
);
assert!(
!style.bold,
"disabled buttons drop the intent's bold emphasis"
);
assert!(style.bg.is_none(), "disabled buttons paint no bg");
}
#[test]
fn row_inline_collapse_shifts_hit_byte_offsets() {
let spec = WidgetSpec::Row {
children: vec![
WidgetSpec::Toggle {
checked: true,
label: "A".into(),
focused: false,
key: Some("a".into()),
},
WidgetSpec::Spacer {
cols: 2,
flex: false,
key: None,
},
WidgetSpec::Toggle {
checked: false,
label: "B".into(),
focused: false,
key: Some("b".into()),
},
],
key: None,
};
let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].text, "[v] A [ ] B\n");
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].widget_key, "a");
assert_eq!(hits[0].buffer_row, 0);
assert_eq!(hits[0].byte_start, 0);
assert_eq!(hits[0].byte_end, 5); assert_eq!(hits[1].widget_key, "b");
assert_eq!(hits[1].buffer_row, 0);
assert_eq!(hits[1].byte_start, 7);
assert_eq!(hits[1].byte_end, 12);
}
#[test]
fn col_stacks_hit_rows() {
let spec = WidgetSpec::Col {
children: vec![
WidgetSpec::Toggle {
checked: false,
label: "row0".into(),
focused: false,
key: Some("k0".into()),
},
WidgetSpec::Toggle {
checked: true,
label: "row1".into(),
focused: false,
key: Some("k1".into()),
},
],
key: None,
};
let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].buffer_row, 0);
assert_eq!(hits[1].buffer_row, 1);
}
#[test]
fn collect_tabbable_visits_widgets_with_keys_in_declaration_order() {
let spec = WidgetSpec::Col {
children: vec![
WidgetSpec::HintBar {
entries: vec![],
key: Some("hb".into()),
},
WidgetSpec::Row {
children: vec![
WidgetSpec::Toggle {
checked: false,
label: "T".into(),
focused: false,
key: Some("t".into()),
},
WidgetSpec::Spacer {
cols: 1,
flex: false,
key: None,
},
WidgetSpec::Button {
label: "B".into(),
focused: false,
intent: ButtonKind::Normal,
key: Some("b".into()),
disabled: false,
},
],
key: None,
},
WidgetSpec::Text {
value: "".into(),
cursor_byte: -1,
focused: false,
label: "".into(),
placeholder: None,
rows: 1,
field_width: 0,
max_visible_chars: 0,
full_width: false,
completions: Vec::new(),
completions_visible_rows: 0,
key: Some("ti".into()),
},
WidgetSpec::Toggle {
checked: false,
label: "no key".into(),
focused: false,
key: None,
},
],
key: None,
};
let mut tabbable = Vec::new();
collect_tabbable(&spec, &mut tabbable);
assert_eq!(tabbable, vec!["t", "b", "ti"]);
}
#[test]
fn first_render_focuses_first_tabbable() {
let spec = WidgetSpec::Row {
children: vec![
WidgetSpec::Toggle {
checked: false,
label: "A".into(),
focused: false,
key: Some("a".into()),
},
WidgetSpec::Toggle {
checked: false,
label: "B".into(),
focused: false,
key: Some("b".into()),
},
],
key: None,
};
let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
assert_eq!(out.focus_key, "a");
assert_eq!(out.tabbable, vec!["a", "b"]);
}
#[test]
fn render_preserves_focus_key_across_re_renders() {
let spec = WidgetSpec::Row {
children: vec![
WidgetSpec::Toggle {
checked: false,
label: "A".into(),
focused: false,
key: Some("a".into()),
},
WidgetSpec::Toggle {
checked: false,
label: "B".into(),
focused: false,
key: Some("b".into()),
},
],
key: None,
};
let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
assert_eq!(out.focus_key, "b");
}
#[test]
fn render_clamps_stale_focus_key_to_first_tabbable() {
let spec = WidgetSpec::Toggle {
checked: false,
label: "Only".into(),
focused: false,
key: Some("only".into()),
};
let out = render_spec(&spec, &HashMap::new(), "stale", u32::MAX);
assert_eq!(out.focus_key, "only");
}
#[test]
fn focused_widget_renders_with_focused_styling() {
let spec = WidgetSpec::Row {
children: vec![
WidgetSpec::Toggle {
checked: false,
label: "A".into(),
focused: false,
key: Some("a".into()),
},
WidgetSpec::Toggle {
checked: false,
label: "B".into(),
focused: false,
key: Some("b".into()),
},
],
key: None,
};
let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
assert_eq!(out.entries.len(), 1, "row collapses inline");
let entry = &out.entries[0];
let focused_overlay = entry
.inline_overlays
.iter()
.find(|o| {
o.style.bg.as_ref().and_then(|c| c.as_theme_key()) == Some("ui.popup_selection_bg")
})
.expect("focused overlay present on B");
assert_eq!(focused_overlay.start, 5);
assert_eq!(focused_overlay.end, 10);
}
#[test]
fn no_tabbables_yields_empty_focus_key() {
let spec = WidgetSpec::Col {
children: vec![WidgetSpec::HintBar {
entries: vec![],
key: None,
}],
key: None,
};
let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
assert_eq!(out.focus_key, "");
assert!(out.tabbable.is_empty());
}
#[test]
fn list_emits_one_entry_and_one_hit_per_item() {
let spec = WidgetSpec::List {
items: vec![
TextPropertyEntry::text("alpha"),
TextPropertyEntry::text("beta"),
TextPropertyEntry::text("gamma"),
],
item_keys: vec!["a".into(), "b".into(), "c".into()],
selected_index: -1,
visible_rows: 10,
focusable: true,
key: None,
};
let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(entries.len(), 10);
assert_eq!(hits.len(), 3);
for (i, h) in hits.iter().enumerate() {
assert_eq!(h.buffer_row, i as u32);
assert_eq!(h.widget_kind, "list");
assert_eq!(h.event_type, "select");
assert_eq!(h.payload["index"], i);
}
assert_eq!(hits[0].widget_key, "a");
assert_eq!(hits[2].widget_key, "c");
}
#[test]
fn list_applies_selection_bg_to_selected_row() {
let spec = WidgetSpec::List {
items: vec![
TextPropertyEntry::text("first"),
TextPropertyEntry::text("second"),
],
item_keys: vec!["x".into(), "y".into()],
selected_index: 1,
visible_rows: 10,
focusable: true,
key: None,
};
let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
assert!(entries[0].style.is_none(), "unselected row keeps no style");
let style = entries[1].style.as_ref().expect("selected row gets style");
assert_eq!(
style.bg.as_ref().and_then(|c| c.as_theme_key()),
Some("ui.popup_selection_bg"),
);
assert!(style.extend_to_line_end);
}
#[test]
fn list_inside_col_offsets_hit_rows_by_preceding_lines() {
let spec = WidgetSpec::Col {
children: vec![
WidgetSpec::HintBar {
entries: vec![HintEntry {
keys: "h".into(),
label: "header".into(),
}],
key: None,
},
WidgetSpec::List {
items: vec![
TextPropertyEntry::text("row0"),
TextPropertyEntry::text("row1"),
],
item_keys: vec!["a".into(), "b".into()],
selected_index: -1,
visible_rows: 10,
key: None,
focusable: true,
},
],
key: None,
};
let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(entries.len(), 11);
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].buffer_row, 1);
assert_eq!(hits[1].buffer_row, 2);
}
#[test]
fn list_payload_includes_absolute_index_and_key() {
let spec = WidgetSpec::List {
items: vec![TextPropertyEntry::text("only")],
item_keys: vec!["match:42".into()],
selected_index: 0,
visible_rows: 10,
focusable: true,
key: None,
};
let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(hits[0].payload["index"], 0);
assert_eq!(hits[0].payload["key"], "match:42");
}
#[test]
fn list_with_missing_key_emits_empty_widget_key() {
let spec = WidgetSpec::List {
items: vec![TextPropertyEntry::text("a"), TextPropertyEntry::text("b")],
item_keys: vec!["only".into()],
selected_index: -1,
visible_rows: 10,
focusable: true,
key: None,
};
let (_, hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(hits[0].widget_key, "only");
assert_eq!(hits[1].widget_key, "");
}
fn make_list(selected: i32, visible: u32, total: usize, key: Option<&str>) -> WidgetSpec {
let items = (0..total)
.map(|i| TextPropertyEntry::text(format!("row{}", i)))
.collect();
let item_keys = (0..total).map(|i| format!("k{}", i)).collect();
WidgetSpec::List {
items,
item_keys,
selected_index: selected,
visible_rows: visible,
focusable: true,
key: key.map(|s| s.to_string()),
}
}
#[test]
fn list_renders_only_visible_window() {
let spec = make_list(-1, 3, 10, Some("L"));
let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(entries.len(), 3);
assert_eq!(hits.len(), 3);
assert_eq!(hits[0].payload["index"], 0);
assert_eq!(hits[2].payload["index"], 2);
}
#[test]
fn list_scrolls_to_keep_selected_below_window_in_view() {
let spec = make_list(5, 3, 10, Some("L"));
let (_entries, hits, state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(hits.len(), 3);
assert_eq!(hits[0].payload["index"], 3);
assert_eq!(hits[2].payload["index"], 5);
let scroll = match state.get("L").unwrap() {
WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
_ => unreachable!(),
};
assert_eq!(scroll, 3);
}
#[test]
fn list_scrolls_to_keep_selected_above_window_in_view() {
let mut prev = HashMap::new();
prev.insert(
"L".into(),
WidgetInstanceState::List {
scroll_offset: 5,
selected_index: 1,
},
);
let spec = make_list(99, 3, 10, Some("L"));
let (_entries, hits, state) = render_no_focus(&spec, &prev);
assert_eq!(hits[0].payload["index"], 1);
let scroll = match state.get("L").unwrap() {
WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
_ => unreachable!(),
};
assert_eq!(scroll, 1);
}
#[test]
fn list_scroll_preserved_when_selection_remains_in_view() {
let mut prev = HashMap::new();
prev.insert(
"L".into(),
WidgetInstanceState::List {
scroll_offset: 4,
selected_index: 5,
},
);
let spec = make_list(99, 3, 10, Some("L"));
let (_entries, hits, state) = render_no_focus(&spec, &prev);
assert_eq!(hits[0].payload["index"], 4);
let scroll = match state.get("L").unwrap() {
WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
_ => unreachable!(),
};
assert_eq!(scroll, 4);
}
#[test]
fn list_clamps_scroll_to_max_when_dataset_is_smaller_than_old_offset() {
let mut prev = HashMap::new();
prev.insert(
"L".into(),
WidgetInstanceState::List {
scroll_offset: 8,
selected_index: -1,
},
);
let spec = make_list(-1, 3, 5, Some("L"));
let (entries, _hits, state) = render_no_focus(&spec, &prev);
assert_eq!(entries.len(), 3);
let scroll = match state.get("L").unwrap() {
WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
_ => unreachable!(),
};
assert_eq!(scroll, 2);
}
#[test]
fn list_does_not_scroll_when_total_smaller_than_visible() {
let spec = make_list(-1, 10, 3, Some("L"));
let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(entries.len(), 10);
let scroll = match state.get("L").unwrap() {
WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
_ => unreachable!(),
};
assert_eq!(scroll, 0);
}
#[test]
fn list_without_key_does_not_persist_state() {
let spec = make_list(5, 3, 10, None);
let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
assert!(
state.is_empty(),
"Lists without a `key` opt out of state preservation"
);
}
#[test]
fn text_input_renders_value_in_brackets() {
let entry = render_text_input("hello", -1, None, false, "", None, 0, 0, false).entry;
assert_eq!(entry.text, "[hello]");
assert!(entry.inline_overlays.is_empty());
}
#[test]
fn text_input_with_label_prefixes_with_label_space() {
let entry = render_text_input("foo", -1, None, false, "Search:", None, 0, 0, false).entry;
assert_eq!(entry.text, "Search: [foo]");
}
#[test]
fn text_input_focused_adds_input_bg_overlay() {
let entry = render_text_input("x", -1, None, true, "", None, 0, 0, false).entry;
assert_eq!(entry.inline_overlays.len(), 1);
let bg = entry.inline_overlays[0].style.bg.as_ref().unwrap();
assert_eq!(bg.as_theme_key(), Some("ui.prompt_bg"));
}
#[test]
fn text_input_focused_with_selection_adds_selection_bg_overlay() {
let entry =
render_text_input("hello world", 5, Some((0, 5)), true, "", None, 0, 0, false).entry;
let sel = entry
.inline_overlays
.iter()
.find(|o| {
o.style.bg.as_ref().and_then(|c| c.as_theme_key())
== Some("ui.text_input_selection_bg")
})
.expect("selection overlay present");
assert_eq!(sel.start, 1);
assert_eq!(sel.end, 6);
}
#[test]
fn text_input_unfocused_skips_selection_overlay() {
let entry =
render_text_input("hello", -1, Some((0, 5)), false, "", None, 0, 0, false).entry;
let has_sel_overlay = entry.inline_overlays.iter().any(|o| {
o.style.bg.as_ref().and_then(|c| c.as_theme_key()) == Some("ui.text_input_selection_bg")
});
assert!(!has_sel_overlay);
}
#[test]
fn text_area_focused_with_selection_emits_per_row_overlays() {
let r = render_text_area("abcd\nefgh", 8, Some((2, 8)), true, "", None, 2, 0, 0, 80);
let row0 = &r.entries[0];
let row1 = &r.entries[1];
let sel0 = row0
.inline_overlays
.iter()
.find(|o| {
o.style.bg.as_ref().and_then(|c| c.as_theme_key())
== Some("ui.text_input_selection_bg")
})
.expect("row 0 selection overlay");
assert_eq!((sel0.start, sel0.end), (2, 4));
let sel1 = row1
.inline_overlays
.iter()
.find(|o| {
o.style.bg.as_ref().and_then(|c| c.as_theme_key())
== Some("ui.text_input_selection_bg")
})
.expect("row 1 selection overlay");
assert_eq!((sel1.start, sel1.end), (0, 3));
}
#[test]
fn text_input_cursor_byte_in_entry_at_value_position() {
let r = render_text_input("abc", 1, None, true, "", None, 0, 0, false);
assert_eq!(r.cursor_byte_in_entry, Some(2));
}
#[test]
fn text_input_cursor_at_end_lands_on_padding_space_not_bracket() {
let r = render_text_input("ab", 2, None, true, "", None, 0, 0, false);
assert_eq!(r.entry.text, "[ab ]");
assert_eq!(r.cursor_byte_in_entry, Some(3));
assert_ne!(r.cursor_byte_in_entry, Some(4), "must not overlap ]");
}
#[test]
fn text_input_unfocused_empty_shows_placeholder_in_muted() {
let entry =
render_text_input("", -1, None, false, "", Some("type here"), 0, 0, false).entry;
assert_eq!(entry.text, "[type here]");
let placeholder_overlay = entry
.inline_overlays
.iter()
.find(|o| o.style.fg.as_ref().and_then(|c| c.as_theme_key()).is_some())
.expect("placeholder fg overlay");
let fg = placeholder_overlay.style.fg.as_ref().unwrap();
assert_eq!(fg.as_theme_key(), Some("editor.whitespace_indicator_fg"));
assert!(placeholder_overlay.style.italic);
}
#[test]
fn text_input_focused_empty_still_shows_placeholder() {
let r = render_text_input("", -1, None, true, "", Some("type here"), 0, 0, false);
assert_eq!(r.entry.text, "[type here]");
assert_eq!(r.cursor_byte_in_entry, Some(1));
}
#[test]
fn text_input_field_width_pads_short_value_unfocused() {
let r = render_text_input("hi", 2, None, false, "", None, 0, 10, false);
assert_eq!(r.entry.text, "[hi ]");
}
#[test]
fn text_input_field_width_focused_adds_cursor_park_space() {
let r = render_text_input("0123456789", 10, None, true, "", None, 0, 10, false);
assert_eq!(r.entry.text, "[0123456789 ]");
assert_eq!(r.cursor_byte_in_entry, Some(11));
assert_ne!(r.cursor_byte_in_entry, Some(12), "must not land on ]");
}
#[test]
fn text_input_field_width_full_width_pads_to_same_size_when_unfocused() {
let r = render_text_input("hi", -1, None, false, "", None, 0, 10, true);
assert_eq!(r.entry.text, "[hi ]"); }
#[test]
fn text_input_field_width_head_truncates_long_value() {
let r = render_text_input(
"0123456789abcdefghijklmnopqrst",
30,
None,
false,
"",
None,
0,
10,
false,
);
assert!(r.entry.text.contains("…lmnopqrst"));
}
#[test]
fn text_input_field_width_clamps_cursor_in_dropped_prefix() {
let r = render_text_input("abcdefghij", 0, None, true, "", None, 0, 5, false);
assert_eq!(r.cursor_byte_in_entry, Some(1 + "…".len()));
}
#[test]
fn text_input_truncates_long_value_keeping_tail_visible() {
let value: String = "0123456789abcdefghij".to_string();
let entry = render_text_input(&value, -1, None, false, "", None, 6, 0, false).entry;
assert_eq!(entry.text, "[…fghij]");
}
#[test]
fn raw_inside_col_offsets_following_hits() {
let spec = WidgetSpec::Col {
children: vec![
WidgetSpec::Raw {
entries: vec![
TextPropertyEntry::text("line0"),
TextPropertyEntry::text("line1"),
TextPropertyEntry::text("line2"),
],
key: None,
},
WidgetSpec::Toggle {
checked: false,
label: "after raw".into(),
focused: false,
key: Some("post".into()),
},
],
key: None,
};
let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(entries.len(), 4);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].buffer_row, 3);
}
fn tnode(text: &str, depth: u32, has_children: bool) -> TreeNode {
TreeNode {
text: TextPropertyEntry::text(text),
depth,
has_children,
checked: None,
}
}
fn make_tree(
nodes: Vec<TreeNode>,
item_keys: Vec<&str>,
selected: i32,
visible: u32,
expanded: Vec<&str>,
key: Option<&str>,
) -> WidgetSpec {
WidgetSpec::Tree {
nodes,
item_keys: item_keys.iter().map(|s| s.to_string()).collect(),
selected_index: selected,
visible_rows: visible,
expanded_keys: expanded.iter().map(|s| s.to_string()).collect(),
checkable: false,
key: key.map(|s| s.to_string()),
}
}
#[test]
fn tree_row_renders_disclosure_glyph_for_internal_collapsed() {
let r = render_tree_row(&tnode("file.txt", 0, true), false, false);
assert!(r.entry.text.starts_with('\u{25B6}'), "starts with ▶");
assert!(r.entry.text.contains("file.txt"));
assert!(r.disclosure_range.is_some());
}
#[test]
fn tree_row_renders_disclosure_glyph_for_internal_expanded() {
let r = render_tree_row(&tnode("file.txt", 0, true), true, false);
assert!(r.entry.text.starts_with('\u{25BC}'), "starts with ▼");
}
#[test]
fn tree_row_leaf_uses_two_spaces_no_disclosure_hit() {
let r = render_tree_row(&tnode("match", 0, false), false, false);
assert!(r.entry.text.starts_with(" "));
assert!(r.entry.text.contains("match"));
assert!(r.disclosure_range.is_none());
}
#[test]
fn tree_row_indents_by_depth_times_two() {
let r = render_tree_row(&tnode("nested", 2, false), false, false);
assert!(r.entry.text.starts_with(" nested"));
}
#[test]
fn tree_row_shifts_plugin_overlays_by_prefix() {
let mut node = tnode("hello", 1, false);
node.text.inline_overlays.push(InlineOverlay {
start: 0,
end: 5,
style: OverlayOptions {
bold: true,
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Byte,
});
let r = render_tree_row(&node, false, false);
let plugin_overlay = r
.entry
.inline_overlays
.iter()
.find(|o| o.style.bold)
.expect("bold overlay carried through");
assert_eq!(plugin_overlay.start, 4);
assert_eq!(plugin_overlay.end, 9);
}
#[test]
fn tree_row_omits_checkbox_when_not_checkable() {
let mut node = tnode("file.rs", 0, false);
node.checked = Some(true);
let r = render_tree_row(&node, false, false);
assert!(r.checkbox_range.is_none());
assert!(!r.entry.text.contains("[v]"));
assert!(!r.entry.text.contains("[ ]"));
}
#[test]
fn tree_row_omits_checkbox_when_checked_is_none() {
let node = tnode("section", 0, false);
let r = render_tree_row(&node, false, true);
assert!(r.checkbox_range.is_none());
assert!(!r.entry.text.contains("[v]"));
assert!(!r.entry.text.contains("[ ]"));
}
#[test]
fn tree_row_renders_checked_glyph_after_disclosure() {
let mut node = tnode("file.rs", 0, true);
node.checked = Some(true);
let r = render_tree_row(&node, true, true);
assert!(r.checkbox_range.is_some(), "checkbox range emitted");
let (cb_start, cb_end) = r.checkbox_range.unwrap();
assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
assert!(r.entry.text.contains("[v] file.rs"));
}
#[test]
fn tree_row_renders_unchecked_glyph_for_leaf() {
let mut node = tnode("match-row", 1, false);
node.checked = Some(false);
let r = render_tree_row(&node, false, true);
let (cb_start, cb_end) = r
.checkbox_range
.expect("checkbox range for leaf with checked: Some");
assert_eq!(&r.entry.text[cb_start..cb_end], "[ ]");
assert!(r.entry.text.starts_with(" [ ] match-row"));
}
#[test]
fn tree_row_checkbox_glyph_byte_range_addresses_correct_text() {
let mut node = tnode("path/with/é", 0, true);
node.checked = Some(true);
let r = render_tree_row(&node, false, true);
let (cb_start, cb_end) = r.checkbox_range.unwrap();
assert!(r.entry.text.is_char_boundary(cb_start));
assert!(r.entry.text.is_char_boundary(cb_end));
assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
}
#[test]
fn tree_node_pad_to_chars_pads_text_before_prefix_offset_shift() {
let mut node = tnode("x", 0, true);
node.text.pad_to_chars = Some(5);
let spec = make_tree(vec![node], vec!["x"], -1, 10, vec!["x"], Some("T"));
let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(entries.len(), 1);
let trimmed = entries[0].text.trim_end_matches('\n');
assert!(
trimmed.ends_with("x "),
"row should end with the padded body, got {trimmed:?}"
);
}
#[test]
fn tree_node_truncate_to_chars_cuts_body_before_prefix_offset_shift() {
let mut node = tnode("abcdefghij", 0, false);
node.text.truncate_to_chars = Some(6);
let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
let trimmed = entries[0].text.trim_end_matches('\n');
assert!(
trimmed.ends_with("abc..."),
"row should end with truncated body, got {trimmed:?}"
);
}
#[test]
fn tree_node_char_unit_overlay_resolves_against_padded_text_and_shifts_by_prefix() {
let mut node = tnode("x", 0, false);
node.text.pad_to_chars = Some(5);
node.text.inline_overlays.push(InlineOverlay {
start: 0,
end: 5,
style: OverlayOptions {
bold: true,
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Char,
});
let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
let entry = &entries[0];
let bold = entry
.inline_overlays
.iter()
.find(|o| o.style.bold)
.expect("bold overlay carried through");
assert_eq!(bold.start, 2);
assert_eq!(bold.end, 7);
}
#[test]
fn tree_node_char_unit_overlay_with_multibyte_body_resolves_correctly() {
let mut node = tnode("éxé", 0, false);
node.text.inline_overlays.push(InlineOverlay {
start: 1,
end: 2,
style: OverlayOptions {
bold: true,
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Char,
});
let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
let entry = &entries[0];
let bold = entry
.inline_overlays
.iter()
.find(|o| o.style.bold)
.expect("bold overlay carried through");
let trimmed = entry.text.trim_end_matches('\n');
assert_eq!(bold.start, 4);
assert_eq!(bold.end, 5);
assert_eq!(&trimmed[bold.start..bold.end], "x");
}
#[test]
fn tree_node_segments_concatenate_into_row_text_with_per_segment_overlays() {
let mut node = tnode("", 0, false);
node.text.segments = vec![
fresh_core::text_property::StyledSegment {
text: "AB".to_string(),
style: None,
overlays: vec![],
},
fresh_core::text_property::StyledSegment {
text: " ".to_string(),
style: None,
overlays: vec![],
},
fresh_core::text_property::StyledSegment {
text: "CD".to_string(),
style: Some(OverlayOptions {
bold: true,
..Default::default()
}),
overlays: vec![],
},
];
let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
let trimmed = entries[0].text.trim_end_matches('\n');
assert!(
trimmed.ends_with("AB CD"),
"row should end with concatenated segments, got {trimmed:?}"
);
let bold = entries[0]
.inline_overlays
.iter()
.find(|o| o.style.bold)
.expect("styled segment overlay carried through");
assert_eq!(&trimmed[bold.start..bold.end], "CD");
}
#[test]
fn tree_node_segment_nested_overlay_shifts_to_segment_position() {
let mut node = tnode("", 0, false);
node.text.segments = vec![
fresh_core::text_property::StyledSegment {
text: "AB".to_string(),
style: None,
overlays: vec![],
},
fresh_core::text_property::StyledSegment {
text: " - ".to_string(),
style: None,
overlays: vec![],
},
fresh_core::text_property::StyledSegment {
text: "CDEFG".to_string(),
style: None,
overlays: vec![InlineOverlay {
start: 0,
end: 3,
style: OverlayOptions {
bold: true,
..Default::default()
},
properties: Default::default(),
unit: OffsetUnit::Char,
}],
},
];
let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
let trimmed = entries[0].text.trim_end_matches('\n');
let bold = entries[0]
.inline_overlays
.iter()
.find(|o| o.style.bold)
.expect("nested overlay carried through");
assert_eq!(&trimmed[bold.start..bold.end], "CDE");
}
#[test]
fn tree_node_segments_with_pad_pad_after_concatenation() {
let mut node = tnode("", 0, false);
node.text.segments = vec![fresh_core::text_property::StyledSegment {
text: "ab".to_string(),
style: None,
overlays: vec![],
}];
node.text.pad_to_chars = Some(5);
let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
let trimmed = entries[0].text.trim_end_matches('\n');
assert!(
trimmed.ends_with("ab "),
"row should be padded after segment concat, got {trimmed:?}"
);
}
#[test]
fn tree_renders_only_top_level_when_nothing_expanded() {
let spec = make_tree(
vec![
tnode("a", 0, true),
tnode("a.0", 1, false),
tnode("a.1", 1, false),
tnode("b", 0, true),
tnode("b.0", 1, false),
],
vec!["a", "a.0", "a.1", "b", "b.0"],
-1,
10,
vec![], Some("T"),
);
let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(entries.len(), 2);
assert!(entries[0].text.contains('a'));
assert!(entries[1].text.contains('b'));
}
#[test]
fn tree_renders_children_of_expanded_nodes() {
let spec = make_tree(
vec![
tnode("a", 0, true),
tnode("a.0", 1, false),
tnode("a.1", 1, false),
tnode("b", 0, true),
tnode("b.0", 1, false),
],
vec!["a", "a.0", "a.1", "b", "b.0"],
-1,
10,
vec!["a"],
Some("T"),
);
let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(entries.len(), 4);
}
#[test]
fn tree_emits_two_hits_per_internal_row_one_per_leaf() {
let spec = make_tree(
vec![tnode("a", 0, true), tnode("a.0", 1, false)],
vec!["a", "a.0"],
-1,
10,
vec!["a"],
Some("T"),
);
let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(hits.len(), 3);
assert_eq!(hits[0].event_type, "expand");
assert_eq!(hits[0].widget_kind, "tree");
assert_eq!(hits[1].event_type, "select");
assert_eq!(hits[2].event_type, "select");
}
#[test]
fn tree_hits_carry_tree_spec_key_and_per_item_key_in_payload() {
let spec = make_tree(
vec![tnode("only", 0, false)],
vec!["only-key"],
-1,
10,
vec![],
Some("matchTree"),
);
let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(hits[0].widget_key, "matchTree");
assert_eq!(hits[0].payload["key"], "only-key");
assert_eq!(hits[0].payload["index"], 0);
}
#[test]
fn tree_persists_expanded_keys_in_instance_state() {
let spec = make_tree(
vec![tnode("a", 0, true), tnode("a.0", 1, false)],
vec!["a", "a.0"],
-1,
10,
vec!["a"],
Some("T"),
);
let (_, _, state) = render_no_focus(&spec, &HashMap::new());
match state.get("T").unwrap() {
WidgetInstanceState::Tree { expanded_keys, .. } => {
assert!(expanded_keys.contains("a"));
}
_ => unreachable!(),
}
}
#[test]
fn tree_instance_state_overrides_spec_expanded_keys() {
let mut prev = HashMap::new();
prev.insert(
"T".into(),
WidgetInstanceState::Tree {
scroll_offset: 0,
selected_index: -1,
expanded_keys: ["b".to_string()].iter().cloned().collect(),
},
);
let spec = make_tree(
vec![
tnode("a", 0, true),
tnode("a.0", 1, false),
tnode("b", 0, true),
tnode("b.0", 1, false),
],
vec!["a", "a.0", "b", "b.0"],
-1,
10,
vec!["a"], Some("T"),
);
let (entries, _hits, _state) = render_no_focus(&spec, &prev);
assert_eq!(entries.len(), 3);
}
#[test]
fn tree_selected_row_gets_focused_bg() {
let spec = make_tree(
vec![tnode("a", 0, false), tnode("b", 0, false)],
vec!["a", "b"],
1,
10,
vec![],
Some("T"),
);
let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
assert!(entries[0].style.is_none());
let style = entries[1].style.as_ref().expect("selected gets style");
assert_eq!(
style.bg.as_ref().and_then(|c| c.as_theme_key()),
Some("ui.popup_selection_bg")
);
assert!(style.extend_to_line_end);
}
#[test]
fn tree_clamps_selection_to_visible_when_selected_node_is_hidden() {
let spec = make_tree(
vec![tnode("a", 0, true), tnode("a.0", 1, false)],
vec!["a", "a.0"],
1,
10,
vec![], Some("T"),
);
let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
match state.get("T").unwrap() {
WidgetInstanceState::Tree { selected_index, .. } => {
assert_eq!(*selected_index, 0);
}
_ => unreachable!(),
}
}
#[test]
fn tree_scrolls_to_keep_selection_in_visible_window() {
let spec = make_tree(
vec![
tnode("0", 0, false),
tnode("1", 0, false),
tnode("2", 0, false),
tnode("3", 0, false),
tnode("4", 0, false),
tnode("5", 0, false),
],
vec!["k0", "k1", "k2", "k3", "k4", "k5"],
4,
3,
vec![],
Some("T"),
);
let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
assert_eq!(entries.len(), 3);
match state.get("T").unwrap() {
WidgetInstanceState::Tree { scroll_offset, .. } => assert_eq!(*scroll_offset, 2),
_ => unreachable!(),
}
}
#[test]
fn tree_tabbable_keys_include_tree_with_key() {
let spec = WidgetSpec::Col {
children: vec![
WidgetSpec::Toggle {
checked: false,
label: "T".into(),
focused: false,
key: Some("toggle".into()),
},
make_tree(
vec![tnode("a", 0, false)],
vec!["a"],
-1,
10,
vec![],
Some("tree"),
),
],
key: None,
};
let mut tabbable = Vec::new();
collect_tabbable(&spec, &mut tabbable);
assert_eq!(tabbable, vec!["toggle", "tree"]);
}
fn make_text_area(
value: &str,
cursor_byte: i32,
focused: bool,
rows: u32,
field_width: u32,
key: Option<&str>,
) -> WidgetSpec {
WidgetSpec::Text {
value: value.into(),
cursor_byte,
focused,
label: String::new(),
placeholder: None,
rows: rows.max(2),
field_width,
max_visible_chars: 0,
full_width: false,
completions: Vec::new(),
completions_visible_rows: 0,
key: key.map(|s| s.into()),
}
}
#[test]
fn text_area_renders_visible_rows_count() {
let spec = make_text_area("hi", -1, false, 3, 10, Some("ta"));
let prev = HashMap::new();
let out = render_spec(&spec, &prev, "", 80);
assert_eq!(out.entries.len(), 3);
}
#[test]
fn text_area_pads_short_lines_to_field_width() {
let spec = make_text_area("hi", -1, false, 1, 6, Some("ta"));
let prev = HashMap::new();
let out = render_spec(&spec, &prev, "", 80);
let first = &out.entries[0];
assert_eq!(first.text, "hi \n");
}
#[test]
fn text_area_truncates_long_line_with_ellipsis() {
let spec = make_text_area("abcdefghi", -1, false, 1, 5, Some("ta"));
let prev = HashMap::new();
let out = render_spec(&spec, &prev, "", 80);
assert_eq!(out.entries[0].text, "abcd…\n");
}
#[test]
fn text_area_focused_adds_input_bg_overlay_per_row() {
let spec = make_text_area("a\nb", -1, true, 3, 4, Some("ta"));
let prev = HashMap::new();
let out = render_spec(&spec, &prev, "ta", 80);
for entry in &out.entries {
let has_bg = entry.inline_overlays.iter().any(|o| {
o.style
.bg
.as_ref()
.and_then(|c| c.as_theme_key())
.map(|k| k == "ui.prompt_bg")
.unwrap_or(false)
});
assert!(has_bg, "every focused row gets input-bg");
}
}
#[test]
fn text_area_publishes_focus_cursor_at_value_position() {
let spec = make_text_area("ab\ncd", 4, true, 3, 6, Some("ta"));
let prev = HashMap::new();
let out = render_spec(&spec, &prev, "ta", 80);
let fc = out.focus_cursor.expect("focused → cursor published");
assert_eq!(fc.buffer_row, 1);
assert_eq!(fc.byte_in_row, 1);
}
#[test]
fn text_area_label_offsets_cursor_buffer_row() {
let spec = WidgetSpec::Text {
value: "hi".into(),
cursor_byte: 1,
focused: true,
label: "Note".into(),
placeholder: None,
rows: 2,
field_width: 6,
max_visible_chars: 0,
full_width: false,
completions: Vec::new(),
completions_visible_rows: 0,
key: Some("ta".into()),
};
let prev = HashMap::new();
let out = render_spec(&spec, &prev, "ta", 80);
assert!(out.entries[0].text.starts_with("Note:"));
let fc = out.focus_cursor.unwrap();
assert_eq!(fc.buffer_row, 1);
}
#[test]
fn text_area_persists_value_and_cursor_in_instance_state() {
let spec = make_text_area("abc", 2, true, 2, 8, Some("ta"));
let prev = HashMap::new();
let out = render_spec(&spec, &prev, "ta", 80);
match out.instance_states.get("ta") {
Some(WidgetInstanceState::Text { editor, .. }) => {
assert_eq!(editor.value(), "abc");
assert_eq!(editor.flat_cursor_byte(), 2);
}
other => panic!("expected Text instance state, got {:?}", other),
}
}
#[test]
fn text_area_instance_state_overrides_spec_value() {
let spec = make_text_area("old", 0, true, 2, 8, Some("ta"));
let mut prev = HashMap::new();
let mut editor = crate::primitives::text_edit::TextEdit::with_text("new");
editor.set_cursor_from_flat(3);
prev.insert(
"ta".into(),
WidgetInstanceState::Text {
editor,
scroll: 0,
completions: Vec::new(),
completion_selected_index: 0,
completion_scroll_offset: 0,
},
);
let out = render_spec(&spec, &prev, "ta", 80);
assert!(out.entries[0].text.starts_with("new"));
}
#[test]
fn text_area_scroll_clamps_to_keep_cursor_visible() {
let spec = make_text_area("a\nb\nc\nd\ne", 8, true, 2, 4, Some("ta"));
let prev = HashMap::new();
let out = render_spec(&spec, &prev, "ta", 80);
match out.instance_states.get("ta") {
Some(WidgetInstanceState::Text { scroll, .. }) => {
assert_eq!(*scroll, 3, "scroll so lines 3..5 are visible");
}
_ => panic!("expected Text instance state"),
}
}
#[test]
fn text_area_unfocused_empty_shows_placeholder_in_first_row() {
let r = render_text_area("", -1, None, false, "", Some("write here"), 2, 12, 0, 80);
assert!(r.entries[0].text.starts_with("write here"));
let fg = r.entries[0]
.inline_overlays
.iter()
.find_map(|o| o.style.fg.as_ref())
.and_then(|c| c.as_theme_key());
assert_eq!(fg, Some("editor.whitespace_indicator_fg"));
}
#[test]
fn text_area_tabbable_keys_include_text_area_with_key() {
let spec = WidgetSpec::Col {
children: vec![
WidgetSpec::Toggle {
checked: false,
label: "T".into(),
focused: false,
key: Some("toggle".into()),
},
make_text_area("", -1, false, 3, 10, Some("note")),
],
key: None,
};
let mut tabbable = Vec::new();
collect_tabbable(&spec, &mut tabbable);
assert_eq!(tabbable, vec!["toggle", "note"]);
}
fn make_text_input(
value: &str,
cursor_byte: i32,
focused: bool,
full_width: bool,
field_width: u32,
key: Option<&str>,
) -> WidgetSpec {
WidgetSpec::Text {
value: value.into(),
cursor_byte,
focused,
label: String::new(),
placeholder: None,
rows: 1,
field_width,
max_visible_chars: 0,
full_width,
completions: Vec::new(),
completions_visible_rows: 0,
key: key.map(|s| s.into()),
}
}
#[test]
fn labeled_section_renders_three_rows_with_legend() {
let spec = WidgetSpec::LabeledSection {
label: "Name".into(),
child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
width_pct: None,
key: None,
};
let prev = HashMap::new();
let out = render_spec(&spec, &prev, "", 20);
assert_eq!(out.entries.len(), 3);
assert!(out.entries[0].text.starts_with("╭─ Name "));
assert!(out.entries[0].text.ends_with("╮\n"));
assert!(out.entries[1].text.starts_with("│ "));
assert!(out.entries[1].text.ends_with(" │\n"));
assert!(out.entries[2].text.starts_with("╰"));
assert!(out.entries[2].text.ends_with("╯\n"));
}
#[test]
fn labeled_section_pads_child_to_inner_width() {
let spec = WidgetSpec::LabeledSection {
label: "".into(),
child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
width_pct: None,
key: None,
};
let prev = HashMap::new();
let out = render_spec(&spec, &prev, "", 16);
let middle = &out.entries[1];
assert_eq!(middle.text.chars().count(), 16 + 1 );
}
#[test]
fn labeled_section_text_full_width_fills_inner_area() {
let spec = WidgetSpec::LabeledSection {
label: "".into(),
child: Box::new(make_text_input("ab", -1, false, true, 0, Some("n"))),
width_pct: None,
key: None,
};
let prev = HashMap::new();
let out = render_spec(&spec, &prev, "", 16);
let middle = &out.entries[1];
assert_eq!(middle.text.chars().count(), 17, "actual: {:?}", middle.text);
assert!(
middle.text.contains("[ab ]"),
"actual: {:?}",
middle.text
);
}
#[test]
fn labeled_section_propagates_focus_cursor_with_offsets() {
let spec = WidgetSpec::LabeledSection {
label: "".into(),
child: Box::new(make_text_input("abc", 3, true, false, 4, Some("n"))),
width_pct: None,
key: None,
};
let prev = HashMap::new();
let out = render_spec(&spec, &prev, "n", 20);
let fc = out.focus_cursor.expect("focused child publishes cursor");
assert_eq!(fc.buffer_row, 1);
let prefix_bytes = LEFT_BORDER_PREFIX.len() as u32;
assert_eq!(fc.byte_in_row, prefix_bytes + 1 + 3);
}
#[test]
fn labeled_section_includes_child_in_tabbable() {
let spec = WidgetSpec::Col {
children: vec![
WidgetSpec::LabeledSection {
label: "Name".into(),
child: Box::new(make_text_input("", -1, false, false, 0, Some("n"))),
width_pct: None,
key: None,
},
WidgetSpec::LabeledSection {
label: "Cmd".into(),
child: Box::new(make_text_input("", -1, false, false, 0, Some("c"))),
width_pct: None,
key: None,
},
],
key: None,
};
let mut tabbable = Vec::new();
collect_tabbable(&spec, &mut tabbable);
assert_eq!(tabbable, vec!["n", "c"]);
}
}