use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use super::{draw_styled_text, ratatui_color, set_cell};
use crate::primitives::form::{
FieldKind, Form, FormFieldMeasure, FormItemMeasure, ValidationState,
};
use crate::primitives::toolbar::ToolbarButton;
use crate::theme::Theme;
use crate::types::{Decoration, WidgetId};
pub fn tui_form_layout(form: &Form, area: Rect) -> crate::primitives::form::FormLayout {
form.layout(area.width as f32, area.height as f32, |i| {
let field = &form.fields[i];
match &field.kind {
FieldKind::ToggleGroup { toggles } => {
let label_w = field.label.visible_width();
let start_x = if label_w > 0 { label_w + 2 } else { 1 };
let items = toggles
.iter()
.map(|t| FormItemMeasure {
id: t.id,
width: t.label.chars().count() as f32,
})
.collect();
FormFieldMeasure::with_items(1.0, start_x as f32, 1.0, items)
}
FieldKind::ButtonRow { buttons } => {
let label_w = field.label.visible_width();
let start_x = if label_w > 0 { label_w + 2 } else { 1 };
let items = buttons
.iter()
.map(|b| {
let icon_w = b
.icon
.as_ref()
.map(|i| {
let gw = i.fallback.chars().count();
if b.label.is_empty() {
gw
} else {
gw + 1
}
})
.unwrap_or(0);
FormItemMeasure {
id: b.id,
width: (b.label.chars().count() + icon_w + 2) as f32,
}
})
.collect();
FormFieldMeasure::with_items(1.0, start_x as f32, 1.0, items)
}
FieldKind::TextArea { visible_rows, .. } => FormFieldMeasure::new(*visible_rows as f32),
FieldKind::Toolbar(toolbar) => {
use super::toolbar::tui_item_width;
let label_w = field.label.visible_width();
let start_x = if label_w > 0 { label_w + 2 } else { 0 };
let items = toolbar
.buttons
.iter()
.map(|btn| {
let id = match btn {
ToolbarButton::Action { id, .. } => *id,
_ => field.id,
};
FormItemMeasure {
id,
width: tui_item_width(btn),
}
})
.collect();
FormFieldMeasure::with_items(1.0, start_x as f32, 0.0, items)
}
FieldKind::SegmentedControl { options, .. } => {
let label_w = field.label.visible_width();
let start_x = if label_w > 0 { label_w + 2 } else { 1 };
let items = options
.iter()
.enumerate()
.map(|(idx, opt)| FormItemMeasure {
id: WidgetId::new(format!("{}__seg_{}", field.id.as_str(), idx)),
width: opt.chars().count() as f32,
})
.collect();
FormFieldMeasure::with_items(1.0, start_x as f32, 1.0, items)
}
_ => FormFieldMeasure::new(1.0),
}
})
}
struct RowCtx<'a> {
area: Rect,
y: u16,
default_fg: ratatui::style::Color,
row_bg: ratatui::style::Color,
field_fg: ratatui::style::Color,
dim_fg: ratatui::style::Color,
accent_fg: ratatui::style::Color,
label_end: usize,
no_label: bool,
is_focused: bool,
theme: &'a Theme,
}
pub fn draw_form(buf: &mut Buffer, area: Rect, form: &Form, theme: &Theme) {
if area.width == 0 || area.height == 0 {
return;
}
let colors = FormColors::from_theme(theme);
let layout = tui_form_layout(form, area);
for visible_field in &layout.visible_fields {
let field = &form.fields[visible_field.field_idx];
let y = area.y + visible_field.bounds.y.round() as u16;
let ctx = build_row_ctx(buf, area, y, form, field, theme, &colors);
match &field.kind {
FieldKind::Label => {
}
FieldKind::Toggle { value } => draw_field_toggle(buf, &ctx, *value),
FieldKind::TextInput {
value,
placeholder,
cursor,
selection_anchor,
} => draw_field_text_input(buf, &ctx, value, placeholder, *cursor, *selection_anchor),
FieldKind::Button => draw_field_button(buf, &ctx, field),
FieldKind::ReadOnly { value } => draw_field_read_only(buf, &ctx, value),
FieldKind::Slider {
value, min, max, ..
} => draw_field_slider(buf, &ctx, *value, *min, *max),
FieldKind::ColorPicker { value } => draw_field_color_picker(buf, &ctx, *value),
FieldKind::Dropdown {
options,
selected_idx,
} => draw_field_dropdown(buf, &ctx, options, *selected_idx),
FieldKind::ToggleGroup { toggles } => {
draw_field_toggle_group(buf, &ctx, visible_field, field, toggles)
}
FieldKind::ButtonRow { buttons } => {
draw_field_button_row(buf, &ctx, visible_field, field, buttons)
}
FieldKind::Toolbar(toolbar) => draw_field_toolbar(buf, &ctx, visible_field, toolbar),
FieldKind::DatePicker(picker) => draw_field_date_picker(buf, &ctx, picker),
FieldKind::TextArea {
value,
placeholder,
cursor,
visible_rows,
} => draw_field_text_area(buf, &ctx, value, placeholder, *cursor, *visible_rows),
FieldKind::PasswordInput {
value,
placeholder,
cursor,
mask_char,
} => draw_field_password_input(buf, &ctx, value, placeholder, *cursor, *mask_char),
FieldKind::SegmentedControl {
options,
selected_idx,
} => draw_field_segmented_control(buf, &ctx, visible_field, options, *selected_idx),
}
paint_validation_indicator(buf, area, y, ctx.row_bg, field, theme);
}
}
struct FormColors {
bg: ratatui::style::Color,
fg: ratatui::style::Color,
hdr_fg: ratatui::style::Color,
hdr_bg: ratatui::style::Color,
sel_bg: ratatui::style::Color,
dim_fg: ratatui::style::Color,
accent_fg: ratatui::style::Color,
}
impl FormColors {
fn from_theme(theme: &Theme) -> Self {
Self {
bg: ratatui_color(theme.tab_bar_bg),
fg: ratatui_color(theme.foreground),
hdr_fg: ratatui_color(theme.header_fg),
hdr_bg: ratatui_color(theme.header_bg),
sel_bg: ratatui_color(theme.selected_bg),
dim_fg: ratatui_color(theme.muted_fg),
accent_fg: ratatui_color(theme.accent_fg),
}
}
}
fn build_row_ctx<'a>(
buf: &mut Buffer,
area: Rect,
y: u16,
form: &Form,
field: &crate::primitives::form::FormField,
theme: &'a Theme,
colors: &FormColors,
) -> RowCtx<'a> {
let is_focused = form.has_focus
&& form
.focused_field
.as_ref()
.is_some_and(|id| id == &field.id);
let is_header = matches!(field.kind, FieldKind::Label);
let (default_fg, row_bg) = match (is_header, is_focused) {
(_, true) => (colors.hdr_fg, colors.sel_bg),
(true, false) => (colors.hdr_fg, colors.hdr_bg),
(false, false) => (colors.fg, colors.bg),
};
for x in area.x..area.x + area.width {
set_cell(buf, x, y, ' ', default_fg, row_bg);
}
let field_fg = if field.disabled {
colors.dim_fg
} else {
default_fg
};
let label_col = 1usize;
let label_end = draw_styled_text(
buf,
area,
y,
label_col,
&field.label,
field_fg,
row_bg,
Decoration::Normal,
colors.dim_fg,
);
let no_label = field.label.visible_width() == 0;
RowCtx {
area,
y,
default_fg,
row_bg,
field_fg,
dim_fg: colors.dim_fg,
accent_fg: colors.accent_fg,
label_end,
no_label,
is_focused,
theme,
}
}
fn paint_validation_indicator(
buf: &mut Buffer,
area: Rect,
y: u16,
row_bg: ratatui::style::Color,
field: &crate::primitives::form::FormField,
theme: &Theme,
) {
let Some(ref vs) = field.validation else {
return;
};
let (indicator, v_fg) = match vs {
ValidationState::Error(_) => ('!', ratatui_color(theme.error_fg)),
ValidationState::Warning(_) => ('\u{26A0}', ratatui_color(theme.warning_fg)),
};
set_cell(buf, area.x, y, indicator, v_fg, row_bg);
}
fn draw_field_toggle(buf: &mut Buffer, ctx: &RowCtx, value: bool) {
let RowCtx {
area,
y,
row_bg,
field_fg,
accent_fg,
label_end,
no_label,
..
} = *ctx;
let glyph = if value { "[x]" } else { "[ ]" };
let w = glyph.chars().count();
let start_col = if no_label {
1
} else {
(area.width as usize).saturating_sub(w + 2)
};
if start_col > label_end + 1 || no_label {
let input_fg = if value { accent_fg } else { field_fg };
for (col, ch) in (start_col..).zip(glyph.chars()) {
if col >= area.width as usize {
break;
}
set_cell(buf, area.x + col as u16, y, ch, input_fg, row_bg);
}
}
}
fn draw_field_text_input(
buf: &mut Buffer,
ctx: &RowCtx,
value: &str,
placeholder: &str,
cursor: Option<usize>,
selection_anchor: Option<usize>,
) {
let RowCtx {
area,
y,
row_bg,
field_fg,
dim_fg,
label_end,
no_label,
..
} = *ctx;
let shown = if value.is_empty() { placeholder } else { value };
let input_fg = if value.is_empty() { dim_fg } else { field_fg };
let (start_col, desired) = if no_label {
let sc = 1usize;
let avail = (area.width as usize).saturating_sub(sc + 2);
(sc, shown.chars().count().min(avail))
} else {
let max_input = (area.width as usize * 2 / 3).max(10);
let d = shown.chars().count().min(max_input);
let sc = (area.width as usize).saturating_sub(d + 2);
(sc, d)
};
let (sel_lo, sel_hi) = if value.is_empty() {
(0, 0)
} else {
match (cursor, selection_anchor) {
(Some(c), Some(a)) if c != a => (c.min(a), c.max(a)),
_ => (0, 0),
}
};
if start_col > label_end + 1 || no_label {
if start_col > 0 && start_col - 1 < area.width as usize {
set_cell(buf, area.x + (start_col - 1) as u16, y, '[', dim_fg, row_bg);
}
let mut col = start_col;
let mut byte = 0usize;
for ch in shown.chars().take(desired) {
if col >= area.width as usize {
break;
}
let in_selection = sel_hi > sel_lo && byte >= sel_lo && byte < sel_hi;
let (ch_fg, ch_bg) = if in_selection {
(row_bg, input_fg)
} else {
(input_fg, row_bg)
};
set_cell(buf, area.x + col as u16, y, ch, ch_fg, ch_bg);
col += 1;
byte += ch.len_utf8();
}
let bracket_col = if no_label {
(area.width as usize).saturating_sub(1)
} else {
col
};
if bracket_col < area.width as usize {
set_cell(buf, area.x + bracket_col as u16, y, ']', dim_fg, row_bg);
}
if let Some(cur) = cursor {
if !value.is_empty() {
let mut byte = 0usize;
let mut char_idx = 0usize;
for ch in shown.chars().take(desired) {
if byte >= cur {
break;
}
byte += ch.len_utf8();
char_idx += 1;
}
let cursor_col = start_col + char_idx;
if cursor_col < area.width as usize {
let ch = shown.chars().nth(char_idx).unwrap_or(' ');
set_cell(buf, area.x + cursor_col as u16, y, ch, row_bg, field_fg);
}
}
}
}
}
fn draw_field_button(buf: &mut Buffer, ctx: &RowCtx, field: &crate::primitives::form::FormField) {
let RowCtx {
area,
y,
default_fg,
row_bg,
field_fg,
dim_fg,
accent_fg,
label_end,
no_label,
is_focused,
..
} = *ctx;
for x in area.x..area.x + (label_end as u16).min(area.width) {
set_cell(buf, x, y, ' ', default_fg, row_bg);
}
let width = field.label.visible_width() + 4;
let start_col = if no_label {
1
} else {
(area.width as usize).saturating_sub(width + 1)
};
if start_col < area.width as usize {
let brk_fg = if is_focused { accent_fg } else { dim_fg };
let text_fg = if field.disabled { dim_fg } else { field_fg };
set_cell(buf, area.x + start_col as u16, y, '<', brk_fg, row_bg);
let after_lt = draw_styled_text(
buf,
area,
y,
start_col + 2,
&field.label,
text_fg,
row_bg,
Decoration::Normal,
dim_fg,
);
if after_lt < area.width as usize {
set_cell(buf, area.x + after_lt as u16, y, ' ', brk_fg, row_bg);
}
if after_lt + 1 < area.width as usize {
set_cell(buf, area.x + (after_lt + 1) as u16, y, '>', brk_fg, row_bg);
}
}
}
fn draw_field_read_only(buf: &mut Buffer, ctx: &RowCtx, value: &crate::types::StyledText) {
let RowCtx {
area,
y,
row_bg,
field_fg,
dim_fg,
label_end,
no_label,
..
} = *ctx;
let w = value.visible_width();
let start_col = if no_label {
1
} else {
(area.width as usize).saturating_sub(w + 2)
};
if start_col > label_end + 1 || no_label {
draw_styled_text(
buf,
area,
y,
start_col,
value,
field_fg,
row_bg,
Decoration::Normal,
dim_fg,
);
}
}
fn draw_field_slider(buf: &mut Buffer, ctx: &RowCtx, value: f32, min: f32, max: f32) {
let RowCtx {
area,
y,
row_bg,
field_fg,
dim_fg,
accent_fg,
label_end,
..
} = *ctx;
let range = (max - min).max(f32::EPSILON);
let frac = ((value - min) / range).clamp(0.0, 1.0);
let track_cells: usize = 12;
let filled = (frac * track_cells as f32).round() as usize;
let value_str = format!("{value:.2}");
let total = track_cells + 2 + value_str.chars().count() + 2;
let start_col = (area.width as usize).saturating_sub(total + 2);
if start_col > label_end + 1 {
let mut col = start_col;
set_cell(buf, area.x + col as u16, y, '[', dim_fg, row_bg);
col += 1;
for i in 0..track_cells {
let ch = if i < filled { '=' } else { '-' };
let fg = if i < filled { accent_fg } else { dim_fg };
set_cell(buf, area.x + col as u16, y, ch, fg, row_bg);
col += 1;
}
set_cell(buf, area.x + col as u16, y, ']', dim_fg, row_bg);
col += 2;
for ch in value_str.chars() {
if col >= area.width as usize {
break;
}
set_cell(buf, area.x + col as u16, y, ch, field_fg, row_bg);
col += 1;
}
}
}
fn draw_field_color_picker(buf: &mut Buffer, ctx: &RowCtx, value: crate::types::Color) {
let RowCtx {
area,
y,
row_bg,
field_fg,
label_end,
..
} = *ctx;
let hex = format!("#{:02x}{:02x}{:02x}", value.r, value.g, value.b);
let total = 2 + hex.chars().count();
let start_col = (area.width as usize).saturating_sub(total + 2);
if start_col > label_end + 1 {
let swatch_fg = ratatui::style::Color::Rgb(value.r, value.g, value.b);
set_cell(
buf,
area.x + start_col as u16,
y,
'\u{25A0}',
swatch_fg,
row_bg,
);
for (col, ch) in (start_col + 2..).zip(hex.chars()) {
if col >= area.width as usize {
break;
}
set_cell(buf, area.x + col as u16, y, ch, field_fg, row_bg);
}
}
}
fn draw_field_dropdown(
buf: &mut Buffer,
ctx: &RowCtx,
options: &[crate::types::StyledText],
selected_idx: usize,
) {
let RowCtx {
area,
y,
row_bg,
field_fg,
dim_fg,
label_end,
..
} = *ctx;
let chosen = options.get(selected_idx).cloned().unwrap_or_default();
let label_w = chosen.visible_width();
let total = label_w + 4;
let start_col = (area.width as usize).saturating_sub(total + 1);
if start_col > label_end + 1 {
draw_styled_text(
buf,
area,
y,
start_col + 1,
&chosen,
field_fg,
row_bg,
Decoration::Normal,
dim_fg,
);
let chev_col = start_col + 1 + label_w + 1;
if chev_col < area.width as usize {
set_cell(buf, area.x + chev_col as u16, y, '\u{25BE}', dim_fg, row_bg);
}
}
}
fn draw_field_toggle_group(
buf: &mut Buffer,
ctx: &RowCtx,
visible_field: &crate::primitives::form::VisibleFormField,
field: &crate::primitives::form::FormField,
toggles: &[crate::primitives::form::ToggleGroupItem],
) {
let RowCtx {
area,
y,
row_bg,
dim_fg,
accent_fg,
..
} = *ctx;
for (item_id, item_rect) in &visible_field.item_bounds {
let toggle = toggles.iter().find(|t| &t.id == item_id);
if let Some(toggle) = toggle {
let col = area.x as f32 + item_rect.x;
let toggle_fg = if toggle.value && !field.disabled {
accent_fg
} else {
dim_fg
};
for (i, ch) in toggle.label.chars().enumerate() {
let cx = col as u16 + i as u16;
if cx < area.x + area.width {
set_cell(buf, cx, y, ch, toggle_fg, row_bg);
}
}
}
}
}
fn draw_field_button_row(
buf: &mut Buffer,
ctx: &RowCtx,
visible_field: &crate::primitives::form::VisibleFormField,
field: &crate::primitives::form::FormField,
buttons: &[crate::primitives::form::ButtonRowItem],
) {
let RowCtx {
area,
y,
row_bg,
field_fg,
dim_fg,
accent_fg,
..
} = *ctx;
for (item_id, item_rect) in &visible_field.item_bounds {
let button = buttons.iter().find(|b| &b.id == item_id);
if let Some(button) = button {
let col = area.x as f32 + item_rect.x;
let btn_fg = if button.disabled || field.disabled {
dim_fg
} else {
field_fg
};
let brk_fg = if button.disabled || field.disabled {
dim_fg
} else {
accent_fg
};
let mut cx = col as u16;
if cx < area.x + area.width {
set_cell(buf, cx, y, '[', brk_fg, row_bg);
}
cx += 1;
if let Some(ref icon) = button.icon {
let glyph = icon.fallback.as_str();
for ch in glyph.chars() {
if cx < area.x + area.width {
set_cell(buf, cx, y, ch, btn_fg, row_bg);
}
cx += 1;
}
if cx < area.x + area.width && !button.label.is_empty() {
set_cell(buf, cx, y, ' ', btn_fg, row_bg);
cx += 1;
}
}
for ch in button.label.chars() {
if cx < area.x + area.width {
set_cell(buf, cx, y, ch, btn_fg, row_bg);
}
cx += 1;
}
if cx < area.x + area.width {
set_cell(buf, cx, y, ']', brk_fg, row_bg);
}
}
}
}
fn draw_field_date_picker(
buf: &mut Buffer,
ctx: &RowCtx,
picker: &crate::primitives::date_picker::DatePicker,
) {
let RowCtx {
area,
y,
row_bg,
field_fg,
label_end,
no_label,
is_focused,
..
} = *ctx;
let date = if is_focused {
picker.focused
} else {
picker.selected.unwrap_or(picker.focused)
};
let text = date.format(picker.locale.format);
let total = text.chars().count();
let start_col = if no_label {
1
} else {
(area.width as usize).saturating_sub(total + 2)
};
if start_col > label_end + 1 || no_label {
for (col, ch) in (start_col..).zip(text.chars()) {
if col >= area.width as usize {
break;
}
set_cell(buf, area.x + col as u16, y, ch, field_fg, row_bg);
}
}
}
fn draw_field_toolbar(
buf: &mut Buffer,
ctx: &RowCtx,
visible_field: &crate::primitives::form::VisibleFormField,
toolbar: &crate::primitives::toolbar::Toolbar,
) {
let RowCtx { area, y, theme, .. } = *ctx;
let start_col = visible_field
.item_bounds
.first()
.map(|(_, r)| r.x.round() as u16)
.unwrap_or(0);
let toolbar_area = Rect::new(
area.x + start_col,
y,
area.width.saturating_sub(start_col),
1,
);
super::toolbar::draw_toolbar(buf, toolbar_area, toolbar, theme, None, None);
}
fn draw_field_text_area(
buf: &mut Buffer,
ctx: &RowCtx,
value: &str,
placeholder: &str,
cursor: Option<usize>,
visible_rows: usize,
) {
let RowCtx {
area,
y,
default_fg,
row_bg,
field_fg,
dim_fg,
..
} = *ctx;
let shown = if value.is_empty() { placeholder } else { value };
let input_fg = if value.is_empty() { dim_fg } else { field_fg };
let rows = visible_rows;
let avail_w = (area.width as usize).saturating_sub(2); let mut chars_iter = shown.chars();
for row in 0..rows {
let row_y = y + row as u16;
if row_y >= area.y + area.height {
break;
}
if row > 0 {
for x in area.x..area.x + area.width {
set_cell(buf, x, row_y, ' ', default_fg, row_bg);
}
}
set_cell(buf, area.x, row_y, '[', dim_fg, row_bg);
let mut col = 1usize;
for _ in 0..avail_w {
if let Some(ch) = chars_iter.next() {
if col < area.width as usize {
set_cell(buf, area.x + col as u16, row_y, ch, input_fg, row_bg);
}
col += 1;
} else {
break;
}
}
let bracket_col = (area.width as usize).saturating_sub(1);
if bracket_col < area.width as usize {
set_cell(buf, area.x + bracket_col as u16, row_y, ']', dim_fg, row_bg);
}
}
if let Some(cur) = cursor {
if !value.is_empty() {
let mut byte = 0usize;
let mut char_idx = 0usize;
for ch in value.chars().take(avail_w) {
if byte >= cur {
break;
}
byte += ch.len_utf8();
char_idx += 1;
}
let cursor_col = 1 + char_idx;
if cursor_col < area.width as usize {
let ch = value.chars().nth(char_idx).unwrap_or(' ');
set_cell(buf, area.x + cursor_col as u16, y, ch, row_bg, field_fg);
}
}
}
}
fn draw_field_password_input(
buf: &mut Buffer,
ctx: &RowCtx,
value: &str,
placeholder: &str,
cursor: Option<usize>,
mask_char: char,
) {
let RowCtx {
area,
y,
row_bg,
field_fg,
dim_fg,
label_end,
no_label,
..
} = *ctx;
let masked: String = value.chars().map(|_| mask_char).collect();
let shown = if value.is_empty() {
placeholder
} else {
masked.as_str()
};
let input_fg = if value.is_empty() { dim_fg } else { field_fg };
let (start_col, desired) = if no_label {
let sc = 1usize;
let avail = (area.width as usize).saturating_sub(sc + 2);
(sc, shown.chars().count().min(avail))
} else {
let max_input = (area.width as usize * 2 / 3).max(10);
let d = shown.chars().count().min(max_input);
let sc = (area.width as usize).saturating_sub(d + 2);
(sc, d)
};
if start_col > label_end + 1 || no_label {
if start_col > 0 && start_col - 1 < area.width as usize {
set_cell(buf, area.x + (start_col - 1) as u16, y, '[', dim_fg, row_bg);
}
let mut col = start_col;
for ch in shown.chars().take(desired) {
if col >= area.width as usize {
break;
}
set_cell(buf, area.x + col as u16, y, ch, input_fg, row_bg);
col += 1;
}
let bracket_col = if no_label {
(area.width as usize).saturating_sub(1)
} else {
col
};
if bracket_col < area.width as usize {
set_cell(buf, area.x + bracket_col as u16, y, ']', dim_fg, row_bg);
}
if let Some(cur) = cursor {
if !value.is_empty() {
let mut byte = 0usize;
let mut char_idx = 0usize;
for ch in value.chars().take(desired) {
if byte >= cur {
break;
}
byte += ch.len_utf8();
char_idx += 1;
}
let cursor_col = start_col + char_idx;
if cursor_col < area.width as usize {
let ch = masked.chars().nth(char_idx).unwrap_or(' ');
set_cell(buf, area.x + cursor_col as u16, y, ch, row_bg, field_fg);
}
}
}
}
}
fn draw_field_segmented_control(
buf: &mut Buffer,
ctx: &RowCtx,
visible_field: &crate::primitives::form::VisibleFormField,
options: &[String],
selected_idx: usize,
) {
let RowCtx {
area,
y,
row_bg,
dim_fg,
accent_fg,
..
} = *ctx;
if !visible_field.item_bounds.is_empty() {
let first_x = visible_field.item_bounds[0].1.x;
let bracket_x = (area.x as f32 + first_x - 1.0).max(area.x as f32);
if (bracket_x as u16) < area.x + area.width {
set_cell(buf, bracket_x as u16, y, '[', dim_fg, row_bg);
}
for (i, (_item_id, item_rect)) in visible_field.item_bounds.iter().enumerate() {
let opt = options.get(i).map(|s| s.as_str()).unwrap_or("");
let opt_fg = if i == selected_idx { accent_fg } else { dim_fg };
let col = area.x as f32 + item_rect.x;
for (j, ch) in opt.chars().enumerate() {
let cx = col as u16 + j as u16;
if cx < area.x + area.width {
set_cell(buf, cx, y, ch, opt_fg, row_bg);
}
}
if i + 1 < options.len() {
let sep_x = col as u16 + opt.chars().count() as u16;
if sep_x < area.x + area.width {
set_cell(buf, sep_x, y, '|', dim_fg, row_bg);
}
}
}
if let Some((_last_id, last_rect)) = visible_field.item_bounds.last() {
let last_opt_len = options.last().map(|s| s.chars().count()).unwrap_or(0);
let close_x = area.x as f32 + last_rect.x + last_opt_len as f32;
if (close_x as u16) < area.x + area.width {
set_cell(buf, close_x as u16, y, ']', dim_fg, row_bg);
}
}
}
}
pub fn draw_settings_chrome(
buf: &mut Buffer,
area: Rect,
header_text: &str,
query: &str,
placeholder: &str,
active: bool,
theme: &Theme,
) {
if area.width == 0 || area.height == 0 {
return;
}
let panel_bg = ratatui_color(theme.tab_bar_bg);
let header_bg = ratatui_color(theme.header_bg);
let header_fg = ratatui_color(theme.header_fg);
let foreground = ratatui_color(theme.foreground);
let muted_fg = ratatui_color(theme.muted_fg);
let selected_bg = ratatui_color(theme.selected_bg);
let accent_fg = ratatui_color(theme.accent_fg);
let header_y = area.y;
for x in area.x..area.x + area.width {
set_cell(buf, x, header_y, ' ', header_fg, header_bg);
}
for (x, ch) in (area.x..).zip(header_text.chars()) {
if x >= area.x + area.width {
break;
}
set_cell(buf, x, header_y, ch, header_fg, header_bg);
}
if area.height < 2 {
return;
}
let search_y = area.y + 1;
let row_bg = if active { selected_bg } else { panel_bg };
for x in area.x..area.x + area.width {
set_cell(buf, x, search_y, ' ', foreground, row_bg);
}
let mut x = area.x;
set_cell(buf, x, search_y, ' ', muted_fg, row_bg);
x += 1;
if x < area.x + area.width {
set_cell(buf, x, search_y, '/', muted_fg, row_bg);
x += 1;
}
if x < area.x + area.width {
set_cell(buf, x, search_y, ' ', muted_fg, row_bg);
x += 1;
}
let show_placeholder = query.is_empty() && !placeholder.is_empty() && !active;
let (text, fg) = if show_placeholder {
(placeholder, muted_fg)
} else {
(query, foreground)
};
for ch in text.chars() {
if x >= area.x + area.width {
break;
}
set_cell(buf, x, search_y, ch, fg, row_bg);
x += 1;
}
if active && x < area.x + area.width {
set_cell(buf, x, search_y, '█', accent_fg, row_bg);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::form::{FieldKind, Form, FormField};
use crate::types::{StyledSpan, StyledText, WidgetId};
fn label(text: &str) -> StyledText {
StyledText {
spans: vec![StyledSpan::plain(text)],
}
}
fn make_form() -> Form {
Form {
id: WidgetId::new("settings"),
fields: vec![
FormField {
id: WidgetId::new("hdr"),
label: label("Editor"),
kind: FieldKind::Label,
disabled: false,
validation: None,
hint: label(""),
},
FormField {
id: WidgetId::new("wrap"),
label: label("wrap"),
kind: FieldKind::Toggle { value: true },
disabled: false,
validation: None,
hint: label(""),
},
],
focused_field: Some(WidgetId::new("wrap")),
scroll_offset: 0,
has_focus: true,
}
}
fn cell_char(buf: &Buffer, x: u16, y: u16) -> char {
buf[(x, y)].symbol().chars().next().unwrap_or(' ')
}
#[test]
fn paints_label_and_toggle_glyph() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
let f = make_form();
draw_form(&mut buf, Rect::new(0, 0, 30, 5), &f, &Theme::default());
let row0: String = (1..7).map(|x| cell_char(&buf, x, 0)).collect();
assert_eq!(row0, "Editor");
let row1: String = (0..5).map(|x| cell_char(&buf, x, 1)).collect();
assert!(row1.contains("wrap"));
let mut found_x = false;
for x in 20..30 {
if cell_char(&buf, x, 1) == 'x' {
found_x = true;
}
}
assert!(found_x, "expected '[x]' near right edge");
}
#[test]
fn focused_row_uses_selected_bg() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
let f = make_form();
let theme = Theme {
selected_bg: crate::types::Color::rgb(99, 0, 0),
..Theme::default()
};
draw_form(&mut buf, Rect::new(0, 0, 30, 5), &f, &theme);
let bg = buf[(0u16, 1u16)].bg;
assert_eq!(bg, ratatui::style::Color::Rgb(99, 0, 0));
}
#[test]
fn focused_row_paints_label_and_value_in_header_fg_not_foreground_or_muted_fg() {
let f = Form {
id: WidgetId::new("f"),
fields: vec![FormField {
id: WidgetId::new("note"),
label: label("Note"),
kind: FieldKind::ReadOnly {
value: label("hello"),
},
disabled: false,
validation: None,
hint: label(""),
}],
focused_field: Some(WidgetId::new("note")),
scroll_offset: 0,
has_focus: true,
};
let theme = Theme {
foreground: crate::types::Color::rgb(10, 10, 10),
header_fg: crate::types::Color::rgb(250, 250, 250),
muted_fg: crate::types::Color::rgb(0, 128, 128),
..Theme::default()
};
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 3));
draw_form(&mut buf, Rect::new(0, 0, 30, 3), &f, &theme);
let hdr_fg = ratatui_color(theme.header_fg);
let label_fg = buf[(1u16, 0u16)].fg;
assert_eq!(
label_fg, hdr_fg,
"focused row's label must use theme.header_fg (Tree's \
on-selection convention), not theme.foreground"
);
let mut hello_col = None;
for x in 0..30u16 {
if cell_char(&buf, x, 0) == 'h'
&& x + 4 < 30
&& cell_char(&buf, x + 1, 0) == 'e'
&& cell_char(&buf, x + 2, 0) == 'l'
{
hello_col = Some(x);
break;
}
}
let hello_col = hello_col.expect("'hello' value must be painted");
let value_fg = buf[(hello_col, 0u16)].fg;
assert_eq!(
value_fg, hdr_fg,
"a focused ReadOnly row's value must track the row's \
on-selection foreground, not an unconditional theme.muted_fg"
);
}
#[test]
fn disabled_field_uses_muted_fg() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
let mut f = make_form();
f.fields[1].disabled = true;
f.has_focus = false;
let theme = Theme {
muted_fg: crate::types::Color::rgb(50, 50, 50),
..Theme::default()
};
draw_form(&mut buf, Rect::new(0, 0, 30, 5), &f, &theme);
let fg = buf[(1u16, 1u16)].fg;
assert_eq!(fg, ratatui::style::Color::Rgb(50, 50, 50));
}
#[test]
fn zero_size_is_a_no_op() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
let f = make_form();
draw_form(&mut buf, Rect::new(0, 0, 0, 5), &f, &Theme::default());
assert_eq!(cell_char(&buf, 0, 0), ' ');
}
#[test]
fn text_input_no_label_spans_full_width() {
let f = Form {
id: WidgetId::new("search"),
fields: vec![FormField {
id: WidgetId::new("query"),
label: label(""),
kind: FieldKind::TextInput {
value: "test".into(),
placeholder: "Search…".into(),
cursor: Some(4),
selection_anchor: None,
},
disabled: false,
validation: None,
hint: label(""),
}],
focused_field: None,
scroll_offset: 0,
has_focus: false,
};
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 3));
draw_form(&mut buf, Rect::new(0, 0, 30, 3), &f, &Theme::default());
let row: String = (0..30).map(|x| cell_char(&buf, x, 0)).collect();
assert!(
row.starts_with("[test"),
"expected full-width '[test...' but got: {row:?}"
);
assert_eq!(cell_char(&buf, 29, 0), ']', "expected ']' at right edge");
}
use crate::primitives::form::{FormHit, ToggleGroupItem};
fn make_toggle_group_form() -> Form {
Form {
id: WidgetId::new("search"),
fields: vec![FormField {
id: WidgetId::new("opts"),
label: label(""),
kind: FieldKind::ToggleGroup {
toggles: vec![
ToggleGroupItem {
id: WidgetId::new("case"),
label: "Aa".into(),
value: true,
},
ToggleGroupItem {
id: WidgetId::new("word"),
label: "Ab|".into(),
value: false,
},
ToggleGroupItem {
id: WidgetId::new("regex"),
label: ".*".into(),
value: false,
},
],
},
disabled: false,
validation: None,
hint: label(""),
}],
focused_field: None,
scroll_offset: 0,
has_focus: false,
}
}
#[test]
fn toggle_group_paints_labels() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
let f = make_toggle_group_form();
draw_form(&mut buf, Rect::new(0, 0, 40, 3), &f, &Theme::default());
let row: String = (0..40).map(|x| cell_char(&buf, x, 0)).collect();
assert!(row.contains("Aa"), "expected 'Aa' in row: {row:?}");
assert!(row.contains("Ab|"), "expected 'Ab|' in row: {row:?}");
assert!(row.contains(".*"), "expected '.*' in row: {row:?}");
}
fn toggle_group_click_hits_correct_item_at(origin_x: u16, origin_y: u16) {
let area = Rect::new(origin_x, origin_y, 40, 3);
let mut buf = Buffer::empty(Rect::new(0, 0, origin_x + 40, origin_y + 3));
let f = make_toggle_group_form();
draw_form(&mut buf, area, &f, &Theme::default());
let layout = f.layout(area.width as f32, area.height as f32, |i| {
let field = &f.fields[i];
match &field.kind {
FieldKind::ToggleGroup { toggles } => {
let label_w = field.label.visible_width();
let start_x = if label_w > 0 { label_w + 2 } else { 1 };
let items = toggles
.iter()
.map(|t| crate::primitives::form::FormItemMeasure {
id: t.id.clone(),
width: t.label.chars().count() as f32,
})
.collect();
FormFieldMeasure::with_items(1.0, start_x as f32, 1.0, items)
}
_ => FormFieldMeasure::new(1.0),
}
});
let mut aa_col = None;
for x in area.x..area.x + area.width {
if cell_char(&buf, x, area.y) == 'A' {
if x + 1 < area.x + area.width && cell_char(&buf, x + 1, area.y) == 'a' {
aa_col = Some(x);
break;
}
}
}
let aa_col = aa_col.expect("'Aa' must be painted");
let hit = layout.hit_test((aa_col - area.x) as f32, 0.0);
assert_eq!(hit, FormHit::Field(WidgetId::new("case")));
let mut ab_col = None;
for x in (aa_col + 2)..area.x + area.width {
if cell_char(&buf, x, area.y) == 'A' {
if x + 1 < area.x + area.width && cell_char(&buf, x + 1, area.y) == 'b' {
ab_col = Some(x);
break;
}
}
}
let ab_col = ab_col.expect("'Ab|' must be painted");
let hit = layout.hit_test((ab_col - area.x) as f32, 0.0);
assert_eq!(hit, FormHit::Field(WidgetId::new("word")));
let mut dot_col = None;
for x in (ab_col + 3)..area.x + area.width {
if cell_char(&buf, x, area.y) == '.'
&& x + 1 < area.x + area.width
&& cell_char(&buf, x + 1, area.y) == '*'
{
dot_col = Some(x);
break;
}
}
let dot_col = dot_col.expect("'.*' must be painted");
let hit = layout.hit_test((dot_col - area.x) as f32, 0.0);
assert_eq!(hit, FormHit::Field(WidgetId::new("regex")));
}
#[test]
fn toggle_group_click_hits_correct_item() {
toggle_group_click_hits_correct_item_at(0, 0);
}
#[test]
fn toggle_group_click_hits_correct_item_at_nonzero_origin() {
toggle_group_click_hits_correct_item_at(7, 13);
}
use crate::primitives::form::ButtonRowItem;
fn make_button_row_form() -> Form {
Form {
id: WidgetId::new("search"),
fields: vec![FormField {
id: WidgetId::new("actions"),
label: label(""),
kind: FieldKind::ButtonRow {
buttons: vec![
ButtonRowItem {
id: WidgetId::new("next"),
label: "Next".into(),
disabled: false,
icon: None,
},
ButtonRowItem {
id: WidgetId::new("replace"),
label: "Repl".into(),
disabled: false,
icon: None,
},
ButtonRowItem {
id: WidgetId::new("all"),
label: "All".into(),
disabled: true,
icon: None,
},
],
},
disabled: false,
validation: None,
hint: label(""),
}],
focused_field: None,
scroll_offset: 0,
has_focus: false,
}
}
#[test]
fn button_row_paints_bracketed_labels() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
let f = make_button_row_form();
draw_form(&mut buf, Rect::new(0, 0, 40, 3), &f, &Theme::default());
let row: String = (0..40).map(|x| cell_char(&buf, x, 0)).collect();
assert!(row.contains("[Next]"), "expected '[Next]' in row: {row:?}");
assert!(row.contains("[Repl]"), "expected '[Repl]' in row: {row:?}");
assert!(row.contains("[All]"), "expected '[All]' in row: {row:?}");
}
fn button_row_click_hits_correct_item_at(origin_x: u16, origin_y: u16) {
let area = Rect::new(origin_x, origin_y, 40, 3);
let mut buf = Buffer::empty(Rect::new(0, 0, origin_x + 40, origin_y + 3));
let f = make_button_row_form();
draw_form(&mut buf, area, &f, &Theme::default());
let layout = f.layout(area.width as f32, area.height as f32, |i| {
let field = &f.fields[i];
match &field.kind {
FieldKind::ButtonRow { buttons } => {
let label_w = field.label.visible_width();
let start_x = if label_w > 0 { label_w + 2 } else { 1 };
let items = buttons
.iter()
.map(|b| crate::primitives::form::FormItemMeasure {
id: b.id.clone(),
width: (b.label.chars().count() + 2) as f32,
})
.collect();
FormFieldMeasure::with_items(1.0, start_x as f32, 1.0, items)
}
_ => FormFieldMeasure::new(1.0),
}
});
let mut next_col = None;
for x in area.x..area.x + area.width {
if cell_char(&buf, x, area.y) == '['
&& x + 1 < area.x + area.width
&& cell_char(&buf, x + 1, area.y) == 'N'
{
next_col = Some(x);
break;
}
}
let next_col = next_col.expect("'[Next]' must be painted");
let hit = layout.hit_test((next_col - area.x) as f32, 0.0);
assert_eq!(hit, FormHit::Field(WidgetId::new("next")));
let hit = layout.hit_test((next_col + 2 - area.x) as f32, 0.0);
assert_eq!(hit, FormHit::Field(WidgetId::new("next")));
let mut repl_col = None;
for x in (next_col + 5)..area.x + area.width {
if cell_char(&buf, x, area.y) == '['
&& x + 1 < area.x + area.width
&& cell_char(&buf, x + 1, area.y) == 'R'
{
repl_col = Some(x);
break;
}
}
let repl_col = repl_col.expect("'[Repl]' must be painted");
let hit = layout.hit_test((repl_col - area.x) as f32, 0.0);
assert_eq!(hit, FormHit::Field(WidgetId::new("replace")));
let mut all_col = None;
for x in (repl_col + 5)..area.x + area.width {
if cell_char(&buf, x, area.y) == '['
&& x + 1 < area.x + area.width
&& cell_char(&buf, x + 1, area.y) == 'A'
{
all_col = Some(x);
break;
}
}
let all_col = all_col.expect("'[All]' must be painted");
let hit = layout.hit_test((all_col - area.x) as f32, 0.0);
assert_eq!(hit, FormHit::Field(WidgetId::new("all")));
}
#[test]
fn button_row_click_hits_correct_item() {
button_row_click_hits_correct_item_at(0, 0);
}
#[test]
fn button_row_click_hits_correct_item_at_nonzero_origin() {
button_row_click_hits_correct_item_at(7, 13);
}
#[test]
fn toggle_group_active_uses_accent_fg() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
let f = make_toggle_group_form();
let theme = Theme {
accent_fg: crate::types::Color::rgb(200, 100, 50),
..Theme::default()
};
draw_form(&mut buf, Rect::new(0, 0, 40, 3), &f, &theme);
let mut aa_col = None;
for x in 0..40u16 {
if cell_char(&buf, x, 0) == 'A' && x + 1 < 40 && cell_char(&buf, x + 1, 0) == 'a' {
aa_col = Some(x);
break;
}
}
let aa_col = aa_col.expect("'Aa' painted");
let fg = buf[(aa_col, 0u16)].fg;
assert_eq!(fg, ratatui::style::Color::Rgb(200, 100, 50));
}
#[test]
fn button_row_disabled_uses_muted_fg() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
let f = make_button_row_form();
let theme = Theme {
muted_fg: crate::types::Color::rgb(80, 80, 80),
accent_fg: crate::types::Color::rgb(200, 100, 50),
..Theme::default()
};
draw_form(&mut buf, Rect::new(0, 0, 40, 3), &f, &theme);
let mut all_col = None;
for x in 0..40u16 {
if cell_char(&buf, x, 0) == '[' && x + 1 < 40 && cell_char(&buf, x + 1, 0) == 'A' {
if x + 2 < 40 && cell_char(&buf, x + 2, 0) == 'l' {
all_col = Some(x);
break;
}
}
}
let all_col = all_col.expect("'[All]' painted");
let bracket_fg = buf[(all_col, 0u16)].fg;
assert_eq!(bracket_fg, ratatui::style::Color::Rgb(80, 80, 80));
}
#[test]
fn password_input_paints_mask_chars() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
let form = Form {
id: WidgetId::new("f"),
fields: vec![FormField {
id: WidgetId::new("pw"),
label: label("Token"),
kind: FieldKind::PasswordInput {
value: "secret".into(),
placeholder: String::new(),
cursor: None,
mask_char: '•',
},
hint: label(""),
disabled: false,
validation: None,
}],
focused_field: None,
scroll_offset: 0,
has_focus: false,
};
draw_form(&mut buf, Rect::new(0, 0, 40, 3), &form, &Theme::default());
let row: String = (0..40).map(|x| cell_char(&buf, x, 0)).collect();
assert!(
row.contains("••••••"),
"password should show mask chars, got: {row:?}"
);
assert!(
!row.contains("secret"),
"password should NOT show plaintext, got: {row:?}"
);
}
#[test]
fn password_input_shows_placeholder_when_empty() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
let form = Form {
id: WidgetId::new("f"),
fields: vec![FormField {
id: WidgetId::new("pw"),
label: label(""),
kind: FieldKind::PasswordInput {
value: String::new(),
placeholder: "Enter key".into(),
cursor: None,
mask_char: '•',
},
hint: label(""),
disabled: false,
validation: None,
}],
focused_field: None,
scroll_offset: 0,
has_focus: false,
};
draw_form(&mut buf, Rect::new(0, 0, 40, 3), &form, &Theme::default());
let row: String = (0..40).map(|x| cell_char(&buf, x, 0)).collect();
assert!(
row.contains("Enter key"),
"empty password should show placeholder, got: {row:?}"
);
}
#[test]
fn segmented_control_paints_all_options() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
let form = Form {
id: WidgetId::new("f"),
fields: vec![FormField {
id: WidgetId::new("scope"),
label: label(""),
kind: FieldKind::SegmentedControl {
options: vec!["Ws".into(), "File".into(), "Sel".into()],
selected_idx: 1,
},
hint: label(""),
disabled: false,
validation: None,
}],
focused_field: None,
scroll_offset: 0,
has_focus: false,
};
draw_form(&mut buf, Rect::new(0, 0, 40, 3), &form, &Theme::default());
let row: String = (0..40).map(|x| cell_char(&buf, x, 0)).collect();
assert!(row.contains("Ws"), "should paint 'Ws', got: {row:?}");
assert!(row.contains("File"), "should paint 'File', got: {row:?}");
assert!(row.contains("Sel"), "should paint 'Sel', got: {row:?}");
}
#[test]
fn segmented_control_hit_test_returns_item_id() {
use crate::primitives::form::FormHit;
let form = Form {
id: WidgetId::new("f"),
fields: vec![FormField {
id: WidgetId::new("scope"),
label: label(""),
kind: FieldKind::SegmentedControl {
options: vec!["Ws".into(), "File".into()],
selected_idx: 0,
},
hint: label(""),
disabled: false,
validation: None,
}],
focused_field: None,
scroll_offset: 0,
has_focus: false,
};
let layout = tui_form_layout(&form, Rect::new(0, 0, 40, 3));
let vis = &layout.visible_fields[0];
assert!(
vis.item_bounds.len() >= 2,
"SegmentedControl should have per-item bounds"
);
let (id0, rect0) = &vis.item_bounds[0];
assert_eq!(id0, &WidgetId::new("scope__seg_0"));
let hit = layout.hit_test(rect0.x + 1.0, rect0.y + 0.5);
assert_eq!(hit, FormHit::Field(WidgetId::new("scope__seg_0")));
}
#[test]
fn textarea_paints_multi_row() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 6));
let form = Form {
id: WidgetId::new("f"),
fields: vec![FormField {
id: WidgetId::new("desc"),
label: label("Desc"),
kind: FieldKind::TextArea {
value: "line one content here".into(),
placeholder: String::new(),
cursor: None,
visible_rows: 3,
},
hint: label(""),
disabled: false,
validation: None,
}],
focused_field: None,
scroll_offset: 0,
has_focus: false,
};
draw_form(&mut buf, Rect::new(0, 0, 40, 6), &form, &Theme::default());
let layout = tui_form_layout(&form, Rect::new(0, 0, 40, 6));
assert_eq!(
layout.visible_fields[0].bounds.height, 3.0,
"TextArea with visible_rows=3 should be 3 cells tall"
);
}
#[test]
fn validation_error_paints_indicator() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
let form = Form {
id: WidgetId::new("f"),
fields: vec![FormField {
id: WidgetId::new("name"),
label: label("Name"),
kind: FieldKind::TextInput {
value: String::new(),
placeholder: String::new(),
cursor: None,
selection_anchor: None,
},
hint: label(""),
disabled: false,
validation: Some(ValidationState::Error("Required".into())),
}],
focused_field: None,
scroll_offset: 0,
has_focus: false,
};
draw_form(&mut buf, Rect::new(0, 0, 40, 3), &form, &Theme::default());
let col0 = cell_char(&buf, 0, 0);
assert_eq!(col0, '!', "error validation should paint '!' at col 0");
}
#[test]
fn validation_warning_paints_indicator() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
let form = Form {
id: WidgetId::new("f"),
fields: vec![FormField {
id: WidgetId::new("name"),
label: label("Name"),
kind: FieldKind::TextInput {
value: "ab".into(),
placeholder: String::new(),
cursor: None,
selection_anchor: None,
},
hint: label(""),
disabled: false,
validation: Some(ValidationState::Warning("Too short".into())),
}],
focused_field: None,
scroll_offset: 0,
has_focus: false,
};
draw_form(&mut buf, Rect::new(0, 0, 40, 3), &form, &Theme::default());
let col0 = cell_char(&buf, 0, 0);
assert_eq!(
col0, '\u{26A0}',
"warning validation should paint warning sign at col 0"
);
}
use crate::primitives::toolbar::{Toolbar, ToolbarButton};
fn make_toolbar_form() -> Form {
Form {
id: WidgetId::new("settings"),
fields: vec![FormField {
id: WidgetId::new("actions"),
label: label(""),
kind: FieldKind::Toolbar(Toolbar {
id: WidgetId::new("tb"),
buttons: vec![
ToolbarButton::Action {
id: WidgetId::new("reset"),
label: "Reset".into(),
icon: None,
key_hint: None,
enabled: true,
is_active: false,
tooltip: String::new(),
},
ToolbarButton::Separator,
ToolbarButton::Action {
id: WidgetId::new("export"),
label: "Export".into(),
icon: None,
key_hint: None,
enabled: true,
is_active: false,
tooltip: String::new(),
},
],
bg: None,
focused_index: None,
}),
hint: label(""),
disabled: false,
validation: None,
}],
focused_field: None,
scroll_offset: 0,
has_focus: false,
}
}
#[test]
fn toolbar_field_paints_bracket() {
let area = Rect::new(0, 0, 40, 3);
let mut buf = Buffer::empty(area);
let f = make_toolbar_form();
draw_form(&mut buf, area, &f, &Theme::default());
let row: String = (0..40).map(|x| cell_char(&buf, x, 0)).collect();
assert!(
row.contains('['),
"toolbar `[` bracket should be painted; row: {row:?}"
);
assert!(
row.contains("Reset"),
"toolbar 'Reset' label should be painted; row: {row:?}"
);
assert!(
row.contains("Export"),
"toolbar 'Export' label should be painted; row: {row:?}"
);
}
#[test]
fn toolbar_field_click_routes_to_action_id() {
let area = Rect::new(0, 0, 40, 3);
let mut buf = Buffer::empty(area);
let f = make_toolbar_form();
draw_form(&mut buf, area, &f, &Theme::default());
let layout = tui_form_layout(&f, area);
let mut reset_col = None;
for x in 0..40u16 {
if cell_char(&buf, x, 0) == 'R'
&& x + 4 < 40
&& cell_char(&buf, x + 1, 0) == 'e'
&& cell_char(&buf, x + 2, 0) == 's'
{
reset_col = Some(x);
break;
}
}
let reset_col = reset_col.expect("'Reset' must be painted");
let hit = layout.hit_test(reset_col as f32, 0.0);
assert_eq!(
hit,
FormHit::Field(WidgetId::new("reset")),
"clicking Reset label should hit the reset action id"
);
}
#[test]
fn toolbar_field_export_click_routes_to_export_id() {
let area = Rect::new(0, 0, 40, 3);
let mut buf = Buffer::empty(area);
let f = make_toolbar_form();
draw_form(&mut buf, area, &f, &Theme::default());
let layout = tui_form_layout(&f, area);
let mut export_col = None;
for x in 0..40u16 {
if cell_char(&buf, x, 0) == 'E'
&& x + 5 < 40
&& cell_char(&buf, x + 1, 0) == 'x'
&& cell_char(&buf, x + 2, 0) == 'p'
{
export_col = Some(x);
break;
}
}
let export_col = export_col.expect("'Export' must be painted");
let hit = layout.hit_test(export_col as f32, 0.0);
assert_eq!(
hit,
FormHit::Field(WidgetId::new("export")),
"clicking Export label should hit the export action id"
);
}
#[test]
fn serde_roundtrip_field_kind_toolbar() {
use crate::primitives::form::FormEvent;
let kind = FieldKind::Toolbar(Toolbar {
id: WidgetId::new("tb"),
buttons: vec![
ToolbarButton::Action {
id: WidgetId::new("reset"),
label: "Reset".into(),
icon: None,
key_hint: None,
enabled: true,
is_active: false,
tooltip: String::new(),
},
ToolbarButton::Separator,
],
bg: None,
focused_index: None,
});
let json = serde_json::to_string(&kind).unwrap();
let back: FieldKind = serde_json::from_str(&json).unwrap();
assert_eq!(kind, back);
let ev = FormEvent::ToolbarButtonClicked {
field_id: WidgetId::new("actions"),
button_id: WidgetId::new("reset"),
};
let json = serde_json::to_string(&ev).unwrap();
let back: FormEvent = serde_json::from_str(&json).unwrap();
assert_eq!(ev, back);
}
#[test]
fn readonly_field_cjk_label_paints_full_display_width_no_gap() {
let f = Form {
id: WidgetId::new("info"),
fields: vec![FormField {
id: WidgetId::new("lang"),
label: label("日本語:"),
kind: FieldKind::ReadOnly { value: label("OK") },
disabled: false,
validation: None,
hint: label(""),
}],
focused_field: None,
scroll_offset: 0,
has_focus: false,
};
let area = Rect::new(0, 0, 30, 3);
let mut buf = Buffer::empty(area);
draw_form(&mut buf, area, &f, &Theme::default());
let label_visible_width = f.fields[0].label.visible_width();
assert_eq!(label_visible_width, 7);
assert_eq!(cell_char(&buf, 1, 0), '日');
assert_eq!(buf[(2u16, 0u16)].symbol(), "");
assert_eq!(cell_char(&buf, 3, 0), '本');
assert_eq!(buf[(4u16, 0u16)].symbol(), "");
assert_eq!(cell_char(&buf, 5, 0), '語');
assert_eq!(buf[(6u16, 0u16)].symbol(), "");
assert_eq!(cell_char(&buf, 7, 0), ':');
assert_eq!(
cell_char(&buf, 1 + label_visible_width as u16, 0),
' ',
"no leftover glyph past the label's measured display width"
);
assert_eq!(cell_char(&buf, 26, 0), 'O');
assert_eq!(cell_char(&buf, 27, 0), 'K');
}
#[test]
fn toggle_group_cjk_label_items_start_flush_with_painted_label() {
let f = Form {
id: WidgetId::new("opts"),
fields: vec![FormField {
id: WidgetId::new("toggles"),
label: label("中文:"),
kind: FieldKind::ToggleGroup {
toggles: vec![ToggleGroupItem {
id: WidgetId::new("a"),
label: "A".into(),
value: true,
}],
},
disabled: false,
validation: None,
hint: label(""),
}],
focused_field: None,
scroll_offset: 0,
has_focus: false,
};
let area = Rect::new(0, 0, 30, 3);
let mut buf = Buffer::empty(area);
draw_form(&mut buf, area, &f, &Theme::default());
let label_visible_width = f.fields[0].label.visible_width();
assert_eq!(label_visible_width, 5);
assert_eq!(cell_char(&buf, 1, 0), '中');
assert_eq!(buf[(2u16, 0u16)].symbol(), "");
assert_eq!(cell_char(&buf, 3, 0), '文');
assert_eq!(buf[(4u16, 0u16)].symbol(), "");
assert_eq!(cell_char(&buf, 5, 0), ':');
let a_col = (1..30)
.find(|&x| cell_char(&buf, x, 0) == 'A')
.expect("toggle item 'A' must be painted");
assert_eq!(a_col, 1 + label_visible_width as u16 + 1);
}
}