use crate::event::Rect;
use crate::types::{Modifiers, StyledText, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Form {
pub id: WidgetId,
pub fields: Vec<FormField>,
pub focused_field: Option<WidgetId>,
#[serde(default)]
pub scroll_offset: usize,
#[serde(default)]
pub has_focus: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ValidationState {
Error(String),
Warning(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FormField {
pub id: WidgetId,
pub label: StyledText,
pub kind: FieldKind,
#[serde(default)]
pub hint: StyledText,
#[serde(default)]
pub disabled: bool,
#[serde(default)]
pub validation: Option<ValidationState>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum FieldKind {
Label,
Toggle { value: bool },
TextInput {
value: String,
#[serde(default)]
placeholder: String,
#[serde(default)]
cursor: Option<usize>,
#[serde(default)]
selection_anchor: Option<usize>,
},
Button,
ReadOnly { value: StyledText },
Slider {
value: f32,
min: f32,
max: f32,
#[serde(default = "slider_default_step")]
step: f32,
},
ColorPicker { value: crate::Color },
Dropdown {
options: Vec<StyledText>,
selected_idx: usize,
},
TextArea {
value: String,
#[serde(default)]
placeholder: String,
#[serde(default)]
cursor: Option<usize>,
#[serde(default = "textarea_default_rows")]
visible_rows: usize,
},
PasswordInput {
value: String,
#[serde(default)]
placeholder: String,
#[serde(default)]
cursor: Option<usize>,
#[serde(default = "password_default_mask")]
mask_char: char,
},
SegmentedControl {
options: Vec<String>,
selected_idx: usize,
},
ToggleGroup { toggles: Vec<ToggleGroupItem> },
ButtonRow { buttons: Vec<ButtonRowItem> },
Toolbar(crate::primitives::toolbar::Toolbar),
DatePicker(crate::primitives::date_picker::DatePicker),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToggleGroupItem {
pub id: WidgetId,
pub label: String,
pub value: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ButtonRowItem {
pub id: WidgetId,
pub label: String,
#[serde(default)]
pub disabled: bool,
#[serde(default)]
pub icon: Option<crate::types::Icon>,
}
fn slider_default_step() -> f32 {
1.0
}
fn textarea_default_rows() -> usize {
3
}
fn password_default_mask() -> char {
'•'
}
#[derive(Debug, Clone, PartialEq)]
pub struct FormFieldMeasure {
pub height: f32,
pub item_measures: Vec<FormItemMeasure>,
pub items_start_x: f32,
pub item_gap: f32,
}
impl FormFieldMeasure {
pub fn new(height: f32) -> Self {
Self {
height,
item_measures: Vec::new(),
items_start_x: 0.0,
item_gap: 0.0,
}
}
pub fn with_items(
height: f32,
items_start_x: f32,
item_gap: f32,
items: Vec<FormItemMeasure>,
) -> Self {
Self {
height,
item_measures: items,
items_start_x,
item_gap,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FormItemMeasure {
pub id: WidgetId,
pub width: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct VisibleFormField {
pub field_idx: usize,
pub id: WidgetId,
pub bounds: Rect,
pub item_bounds: Vec<(WidgetId, Rect)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormHit {
Field(WidgetId),
Empty,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FormLayout {
pub viewport_width: f32,
pub viewport_height: f32,
pub visible_fields: Vec<VisibleFormField>,
pub hit_regions: Vec<(Rect, FormHit)>,
pub resolved_scroll_offset: usize,
}
impl FormLayout {
pub fn hit_test(&self, x: f32, y: f32) -> FormHit {
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
FormHit::Empty
}
}
impl Form {
pub fn layout<F>(
&self,
viewport_width: f32,
viewport_height: f32,
measure_field: F,
) -> FormLayout
where
F: Fn(usize) -> FormFieldMeasure,
{
let mut visible_fields: Vec<VisibleFormField> = Vec::new();
let mut hit_regions: Vec<(Rect, FormHit)> = Vec::new();
let resolved_scroll_offset = crate::primitives::scrollbar::clamp_scroll_offset(
self.scroll_offset,
self.fields.len(),
);
struct Row {
idx: usize,
full_height: f32,
clipped_height: f32,
item_measures: Vec<FormItemMeasure>,
items_start_x: f32,
item_gap: f32,
}
let mut rows: Vec<Row> = Vec::new();
let mut y = 0.0_f32;
for i in resolved_scroll_offset..self.fields.len() {
if y >= viewport_height {
break;
}
let m = measure_field(i);
let remaining = viewport_height - y;
let clipped_height = m.height.min(remaining).max(0.0);
if clipped_height <= 0.0 {
break;
}
y += m.height;
rows.push(Row {
idx: i,
full_height: m.height,
clipped_height,
item_measures: m.item_measures,
items_start_x: m.items_start_x,
item_gap: m.item_gap,
});
}
let full_heights: Vec<f32> = rows.iter().map(|r| r.full_height).collect();
let ys = row_y_positions(viewport_width, &full_heights);
for (row, y) in rows.into_iter().zip(ys) {
let bounds = Rect::new(0.0, y, viewport_width, row.clipped_height);
let id = self.fields[row.idx].id;
let mut item_bounds = Vec::new();
if !row.item_measures.is_empty() {
let widths: Vec<f32> = row.item_measures.iter().map(|item| item.width).collect();
let item_xs =
item_x_positions(row.items_start_x, row.clipped_height, row.item_gap, &widths);
for (item, item_x) in row.item_measures.iter().zip(item_xs) {
let item_rect = Rect::new(item_x, y, item.width, row.clipped_height);
hit_regions.push((item_rect, FormHit::Field(item.id)));
item_bounds.push((item.id, item_rect));
}
}
hit_regions.push((bounds, FormHit::Field(id)));
visible_fields.push(VisibleFormField {
field_idx: row.idx,
id,
bounds,
item_bounds,
});
}
FormLayout {
viewport_width,
viewport_height,
visible_fields,
hit_regions,
resolved_scroll_offset,
}
}
}
#[cfg(feature = "layout")]
fn row_y_positions(viewport_width: f32, heights: &[f32]) -> Vec<f32> {
crate::flex::pack_1d(
crate::flex::FlexDirection::Column,
0.0,
heights,
0.0,
viewport_width,
)
}
#[cfg(not(feature = "layout"))]
fn row_y_positions(_viewport_width: f32, heights: &[f32]) -> Vec<f32> {
let mut y = 0.0_f32;
let mut ys = Vec::with_capacity(heights.len());
for &h in heights {
ys.push(y);
y += h;
}
ys
}
#[cfg(feature = "layout")]
fn item_x_positions(origin_x: f32, row_height: f32, gap: f32, widths: &[f32]) -> Vec<f32> {
crate::flex::pack_1d(
crate::flex::FlexDirection::Row,
origin_x,
widths,
gap,
row_height,
)
}
#[cfg(not(feature = "layout"))]
fn item_x_positions(origin_x: f32, _row_height: f32, gap: f32, widths: &[f32]) -> Vec<f32> {
let mut x = origin_x;
let mut xs = Vec::with_capacity(widths.len());
for &w in widths {
xs.push(x);
x += w + gap;
}
xs
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
mod native_surface_paint {
use super::{FieldKind, Form, FormLayout, ValidationState};
use crate::event::Rect;
use crate::paint_surface::PaintSurface;
use crate::paint_geometry::selected_item_fill;
use crate::text_util::snap_to_char_boundary;
use crate::theme::Theme;
use crate::types::{Color, StyledText};
fn translate(r: &Rect, origin: crate::Point) -> Rect {
Rect::new(origin.x + r.x, origin.y + r.y, r.width, r.height)
}
fn plain_text(t: &StyledText) -> String {
t.spans.iter().map(|s| s.text.as_str()).collect()
}
pub(crate) fn paint(
form: &Form,
flayout: &FormLayout,
surface: &mut dyn PaintSurface,
theme: &Theme,
origin: crate::Point,
) {
let form_rect = Rect::new(
origin.x,
origin.y,
flayout.viewport_width,
flayout.viewport_height,
);
surface.surface_fill_rect(form_rect, theme.tab_bar_bg);
for vf in &flayout.visible_fields {
let Some(field) = form.fields.get(vf.field_idx) else {
continue;
};
let row_rect = translate(&vf.bounds, origin);
let row_h_full = row_rect.height;
let validation_info = field.validation.as_ref().and_then(|vs| {
let (color, msg) = match vs {
ValidationState::Error(m) => (theme.error_fg, m.as_str()),
ValidationState::Warning(m) => (theme.warning_fg, m.as_str()),
};
if msg.is_empty() {
None
} else {
Some((color, msg))
}
});
let message_h = validation_info
.map(|(_, msg)| surface.surface_measure_text(msg).1)
.unwrap_or(0.0);
let row_h = (row_h_full - message_h).max(row_h_full * 0.5);
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) = if is_focused {
(theme.header_fg, theme.selected_bg)
} else if is_header {
(theme.header_fg, theme.header_bg)
} else {
(theme.foreground, theme.tab_bar_bg)
};
surface.surface_fill_rect(row_rect, row_bg);
let field_fg = if field.disabled {
theme.muted_fg
} else {
default_fg
};
let label_text = plain_text(&field.label);
let (label_w, label_h) = surface.surface_measure_text(&label_text);
let label_x = row_rect.x + 6.0;
let label_y = row_rect.y + (row_h - label_h) / 2.0;
surface.surface_draw_text_run(
Rect::new(label_x, label_y, label_w, label_h),
&label_text,
field_fg,
);
let label_right = label_x + label_w;
let no_label = label_text.is_empty();
let input_right = row_rect.x + row_rect.width - 8.0;
match &field.kind {
FieldKind::Label => {}
FieldKind::Toggle { value } => {
let fg = if *value && !field.disabled {
theme.accent_fg
} else {
field_fg
};
let box_size = (row_h - 8.0).clamp(10.0, 16.0);
let ix = if no_label {
label_x
} else {
input_right - box_size
};
if no_label || ix > label_right + 8.0 {
let iy = row_rect.y + (row_h - box_size) / 2.0;
let box_rect = Rect::new(ix, iy, box_size, box_size);
if *value {
surface.surface_fill_rounded_rect(box_rect, 2.0, fg);
let check_fg = theme.background;
surface.surface_draw_line(
crate::Point::new(
box_rect.x + box_size * 0.22,
box_rect.y + box_size * 0.52,
),
crate::Point::new(
box_rect.x + box_size * 0.42,
box_rect.y + box_size * 0.75,
),
check_fg,
1.5,
);
surface.surface_draw_line(
crate::Point::new(
box_rect.x + box_size * 0.42,
box_rect.y + box_size * 0.75,
),
crate::Point::new(
box_rect.x + box_size * 0.8,
box_rect.y + box_size * 0.25,
),
check_fg,
1.5,
);
} else {
surface.surface_stroke_rect(box_rect, fg, 1.0);
}
}
}
FieldKind::TextInput {
value,
placeholder,
cursor,
selection_anchor,
} => {
paint_text_input_box(
surface,
row_rect,
label_right,
no_label,
input_right,
value,
placeholder,
*cursor,
*selection_anchor,
field_fg,
theme.muted_fg,
theme.selection_bg,
theme.accent_fg,
false,
'\0',
);
}
FieldKind::PasswordInput {
value,
placeholder,
cursor,
mask_char,
} => {
paint_text_input_box(
surface,
row_rect,
label_right,
no_label,
input_right,
value,
placeholder,
*cursor,
None,
field_fg,
theme.muted_fg,
theme.selection_bg,
theme.accent_fg,
true,
*mask_char,
);
}
FieldKind::TextArea {
value,
placeholder,
cursor,
..
} => {
let first_line = value.lines().next().unwrap_or("");
paint_text_input_box(
surface,
row_rect,
label_right,
no_label,
input_right,
first_line,
placeholder,
*cursor,
None,
field_fg,
theme.muted_fg,
theme.selection_bg,
theme.accent_fg,
false,
'\0',
);
}
FieldKind::Button => {
let cap = label_text.clone();
let total_w = label_w + 24.0;
let ix = if no_label {
label_x
} else {
input_right - total_w
};
if no_label || ix > row_rect.x + 8.0 {
let brk = if is_focused {
theme.accent_fg
} else {
theme.muted_fg
};
let (_, h) = surface.surface_measure_text("<");
let y = row_rect.y + (row_h - h) / 2.0;
surface.surface_draw_text_run(Rect::new(ix, y, 12.0, h), "<", brk);
let text_fg = if field.disabled {
theme.muted_fg
} else {
field_fg
};
surface.surface_draw_text_run(
Rect::new(ix + 12.0, y, label_w, h),
&cap,
text_fg,
);
surface.surface_draw_text_run(
Rect::new(ix + 12.0 + label_w + 4.0, y, 12.0, h),
">",
brk,
);
}
}
FieldKind::ReadOnly { value } => {
let text = plain_text(value);
let (w, h) = surface.surface_measure_text(&text);
let ix = if no_label { label_x } else { input_right - w };
if no_label || ix > label_right + 8.0 {
let iy = row_rect.y + (row_h - h) / 2.0;
surface.surface_draw_text_run(Rect::new(ix, iy, w, h), &text, field_fg);
}
}
FieldKind::Slider {
value, min, max, ..
} => {
let track_w = 80.0_f32;
let range = (max - min).max(f32::EPSILON);
let frac = ((value - min) / range).clamp(0.0, 1.0);
let value_str = format!("{value:.2}");
let (value_w, value_h) = surface.surface_measure_text(&value_str);
let total = track_w + 8.0 + value_w;
let ix = input_right - total;
if ix > label_right + 8.0 {
let track_y = row_rect.y + row_h / 2.0 - 2.0;
surface.surface_fill_rect(
Rect::new(ix, track_y, track_w, 4.0),
theme.muted_fg,
);
surface.surface_fill_rect(
Rect::new(ix, track_y, track_w * frac, 4.0),
theme.accent_fg,
);
let vy = row_rect.y + (row_h - value_h) / 2.0;
surface.surface_draw_text_run(
Rect::new(ix + track_w + 8.0, vy, value_w, value_h),
&value_str,
field_fg,
);
}
}
FieldKind::ColorPicker { value } => {
let hex = format!("#{:02x}{:02x}{:02x}", value.r, value.g, value.b);
let (hw, hh) = surface.surface_measure_text(&hex);
let swatch = 12.0_f32;
let total = swatch + 6.0 + hw;
let ix = input_right - total;
if ix > label_right + 8.0 {
let sy = row_rect.y + (row_h - swatch) / 2.0;
surface.surface_fill_rect(Rect::new(ix, sy, swatch, swatch), *value);
let ty = row_rect.y + (row_h - hh) / 2.0;
surface.surface_draw_text_run(
Rect::new(ix + swatch + 6.0, ty, hw, hh),
&hex,
field_fg,
);
}
}
FieldKind::Dropdown {
options,
selected_idx,
} => {
let chosen = options
.get(*selected_idx)
.map(plain_text)
.unwrap_or_default();
let (cw, ch) = surface.surface_measure_text(&chosen);
let (chev_w, _) = surface.surface_measure_text("\u{25BE}");
let total = cw + 4.0 + chev_w;
let ix = input_right - total;
if ix > label_right + 8.0 {
let ty = row_rect.y + (row_h - ch) / 2.0;
surface.surface_draw_text_run(Rect::new(ix, ty, cw, ch), &chosen, field_fg);
surface.surface_draw_text_run(
Rect::new(ix + cw + 4.0, ty, chev_w, ch),
"\u{25BE}",
theme.muted_fg,
);
}
}
FieldKind::ToggleGroup { toggles } => {
for (item_id, item_rect) in &vf.item_bounds {
if let Some(t) = toggles.iter().find(|t| &t.id == item_id) {
let on = t.value && !field.disabled;
let fg = if on { theme.accent_fg } else { theme.muted_fg };
let r = translate(item_rect, origin);
if let Some((pill, bg)) = selected_item_fill(r, on, theme.selected_bg) {
surface.surface_fill_rect(pill, bg);
}
surface.surface_draw_text_run(r, &t.label, fg);
}
}
}
FieldKind::ButtonRow { buttons } => {
for (item_id, item_rect) in &vf.item_bounds {
if let Some(b) = buttons.iter().find(|b| &b.id == item_id) {
let r = translate(item_rect, origin);
let disabled = b.disabled || field.disabled;
let bg = if disabled { row_bg } else { theme.hover_bg };
surface.surface_fill_rect(r, bg);
let fg = if disabled { theme.muted_fg } else { field_fg };
let mut tx = r.x + 4.0;
if let Some(ref icon) = b.icon {
let (iw, ih) = surface.surface_measure_text(&icon.fallback);
let iy = r.y + (r.height - ih) / 2.0;
surface.surface_draw_text_run(
Rect::new(tx, iy, iw, ih),
&icon.fallback,
fg,
);
tx += iw + 4.0;
}
let (lw, lh) = surface.surface_measure_text(&b.label);
let ly = r.y + (r.height - lh) / 2.0;
surface.surface_draw_text_run(Rect::new(tx, ly, lw, lh), &b.label, fg);
}
}
}
FieldKind::SegmentedControl {
options,
selected_idx,
} => {
for (i, (_item_id, item_rect)) in vf.item_bounds.iter().enumerate() {
let opt = options.get(i).map(|s| s.as_str()).unwrap_or("");
let fg = if i == *selected_idx {
theme.accent_fg
} else {
theme.muted_fg
};
let r = translate(item_rect, origin);
if let Some((pill, bg)) =
selected_item_fill(r, i == *selected_idx, theme.selected_bg)
{
surface.surface_fill_rect(pill, bg);
}
let (tw, th) = surface.surface_measure_text(opt);
let ty = r.y + (r.height - th) / 2.0;
surface.surface_draw_text_run(Rect::new(r.x, ty, tw, th), opt, fg);
}
}
FieldKind::Toolbar(_) => {
}
FieldKind::DatePicker(picker) => {
let date = if is_focused {
picker.focused
} else {
picker.selected.unwrap_or(picker.focused)
};
let text = date.format(picker.locale.format);
let (tw, th) = surface.surface_measure_text(&text);
let ix = if no_label { label_x } else { input_right - tw };
if no_label || ix > label_right + 8.0 {
let ty = row_rect.y + (row_h - th) / 2.0;
surface.surface_draw_text_run(Rect::new(ix, ty, tw, th), &text, field_fg);
}
}
}
if let Some((color, msg)) = validation_info {
let strip_y = row_rect.y + row_h;
let strip_h = (row_h_full - row_h).max(0.0);
surface.surface_fill_rect(
Rect::new(
row_rect.x + 2.0,
strip_y + (strip_h - 3.0).max(0.0) / 2.0,
3.0,
3.0,
),
color,
);
let (mw, mh) = surface.surface_measure_text(msg);
let my = strip_y + (strip_h - mh).max(0.0) / 2.0;
surface.surface_draw_text_run(Rect::new(row_rect.x + 8.0, my, mw, mh), msg, color);
}
}
}
#[allow(clippy::too_many_arguments)]
fn paint_text_input_box(
surface: &mut dyn PaintSurface,
row_rect: Rect,
label_right: f32,
no_label: bool,
input_right: f32,
value: &str,
placeholder: &str,
cursor: Option<usize>,
selection_anchor: Option<usize>,
field_fg: Color,
dim_fg: Color,
sel_bg: Color,
accent_fg: Color,
masked: bool,
mask_char: char,
) {
let masked_value: String;
let shown: &str = if value.is_empty() {
placeholder
} else if masked {
masked_value = value.chars().map(|_| mask_char).collect();
&masked_value
} else {
value
};
let input_fg = if value.is_empty() { dim_fg } else { field_fg };
let row_h = row_rect.height;
let (shown_w, shown_h) = surface.surface_measure_text(shown);
let (ix, _dw, box_right) = if no_label {
let ix = row_rect.x + 6.0;
let box_r = input_right - 4.0;
let avail = (box_r - ix - 8.0).max(0.0);
(ix, shown_w.min(avail), box_r)
} else {
let max_width = (row_rect.width * 0.6).max(80.0);
let dw = shown_w.min(max_width);
let ix = input_right - dw - 14.0;
(ix, dw, ix + 8.0 + dw + 2.0)
};
if !(no_label || ix > label_right + 8.0) {
return;
}
surface.surface_stroke_rect(
Rect::new(ix, row_rect.y + 2.0, (box_right - ix).max(1.0), row_h - 4.0),
dim_fg,
1.0,
);
let y = row_rect.y + (row_h - shown_h) / 2.0;
let has_sel = !masked
&& matches!((cursor, selection_anchor), (Some(c), Some(a)) if c != a && !value.is_empty());
if has_sel {
let (c, a) = (cursor.unwrap(), selection_anchor.unwrap());
let (lo, hi) = (c.min(a), c.max(a));
let lo = snap_to_char_boundary(shown, lo);
let hi = snap_to_char_boundary(shown, hi);
let prefix = &shown[..lo];
let sel_text = &shown[lo..hi];
let suffix = &shown[hi..];
let (pw, _) = surface.surface_measure_text(prefix);
let (sw, _) = surface.surface_measure_text(sel_text);
surface.surface_fill_rect(
Rect::new(ix + 8.0 + pw, row_rect.y + 2.0, sw, row_h - 4.0),
sel_bg,
);
surface.surface_draw_text_run(Rect::new(ix + 8.0, y, pw, shown_h), prefix, input_fg);
surface.surface_draw_text_run(
Rect::new(ix + 8.0 + pw, y, sw, shown_h),
sel_text,
field_fg,
);
surface.surface_draw_text_run(
Rect::new(ix + 8.0 + pw + sw, y, shown_w - pw - sw, shown_h),
suffix,
input_fg,
);
} else {
surface.surface_draw_text_run(
Rect::new(ix + 8.0, y, shown_w, shown_h),
shown,
input_fg,
);
}
if !masked {
if let Some(cur) = cursor {
if !value.is_empty() {
let prefix = &shown[..snap_to_char_boundary(shown, cur)];
let (pw, _) = surface.surface_measure_text(prefix);
let cx = ix + 8.0 + pw;
surface.surface_fill_rect(
Rect::new(cx, row_rect.y + 3.0, 1.5, row_h - 6.0),
accent_fg,
);
}
}
} else if let Some(cur) = cursor {
if !value.is_empty() {
let char_pos = value[..snap_to_char_boundary(value, cur)].chars().count();
let prefix: String = shown.chars().take(char_pos).collect();
let (pw, _) = surface.surface_measure_text(&prefix);
let cx = ix + 8.0 + pw;
surface.surface_fill_rect(
Rect::new(cx, row_rect.y + 3.0, 1.5, row_h - 6.0),
accent_fg,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::Viewport;
use crate::primitives::form::{FormField, ToggleGroupItem};
use crate::primitives::layout_metrics::{form_field_measure, form_row_height, TextMeasure};
use crate::types::WidgetId;
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
text_runs: Vec<(Rect, String, Color)>,
strokes: Vec<(Rect, Color, f32)>,
}
struct FixedMeasure;
impl TextMeasure for FixedMeasure {
fn width_of(&self, text: &str) -> f32 {
text.chars().count() as f32 * 6.0
}
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(320.0, 160.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
14.0
}
fn surface_char_width(&self) -> f32 {
6.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(FixedMeasure.width_of(text), 12.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, rect: Rect, color: Color, stroke_width: f32) {
self.strokes.push((rect, color, stroke_width));
}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.text_runs.push((rect, text.to_string(), color));
}
fn surface_draw_line(
&mut self,
_from: crate::Point,
_to: crate::Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, _rect: Rect) {}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn paint_recorded(form: &Form) -> (RecordingSurface, FormLayout) {
let row_h = form_row_height(14.0);
let flayout = form.layout(320.0, 160.0, |i| {
form_field_measure(&form.fields[i], row_h, &FixedMeasure)
});
let mut surface = RecordingSurface::default();
paint(
form,
&flayout,
&mut surface,
&Theme::default(),
crate::Point::new(0.0, 0.0),
);
(surface, flayout)
}
fn one_field_form(id: &str, kind: FieldKind) -> Form {
Form {
id: WidgetId::new("form"),
fields: vec![FormField {
id: WidgetId::new(id),
label: StyledText::default(),
kind,
hint: StyledText::default(),
disabled: false,
validation: None,
}],
focused_field: None,
scroll_offset: 0,
has_focus: false,
}
}
fn expected_pill(item: &Rect) -> Rect {
Rect::new(item.x, item.y + 2.0, item.width, item.height - 4.0)
}
#[test]
fn toggle_group_fills_selected_bg_behind_on_items_only() {
let form = one_field_form(
"flags",
FieldKind::ToggleGroup {
toggles: vec![
ToggleGroupItem {
id: WidgetId::new("case"),
label: "Aa".into(),
value: false,
},
ToggleGroupItem {
id: WidgetId::new("regex"),
label: ".*".into(),
value: true,
},
],
},
);
let (surface, flayout) = paint_recorded(&form);
let theme = Theme::default();
let items = &flayout.visible_fields[0].item_bounds;
let on = &items
.iter()
.find(|(id, _)| id == &WidgetId::new("regex"))
.expect("on toggle laid out")
.1;
let off = &items
.iter()
.find(|(id, _)| id == &WidgetId::new("case"))
.expect("off toggle laid out")
.1;
assert!(
surface
.fills
.contains(&(expected_pill(on), theme.selected_bg)),
"on toggle should fill selected_bg inset 2 vertically; fills were {:?}",
surface.fills,
);
assert!(
!surface
.fills
.iter()
.any(|(r, c)| r.x == off.x && *c == theme.selected_bg),
"off toggle must not paint a selected_bg pill; fills were {:?}",
surface.fills,
);
}
#[test]
fn segmented_control_fills_selected_bg_behind_the_selected_segment_only() {
let form = one_field_form(
"scope",
FieldKind::SegmentedControl {
options: vec!["File".into(), "Folder".into(), "Project".into()],
selected_idx: 1,
},
);
let (surface, flayout) = paint_recorded(&form);
let theme = Theme::default();
let items = &flayout.visible_fields[0].item_bounds;
assert_eq!(items.len(), 3, "three segments should be laid out");
assert!(
surface
.fills
.contains(&(expected_pill(&items[1].1), theme.selected_bg)),
"selected segment should fill selected_bg inset 2 vertically; \
fills were {:?}",
surface.fills,
);
for idx in [0usize, 2] {
let seg = &items[idx].1;
assert!(
!surface
.fills
.iter()
.any(|(r, c)| r.x == seg.x && *c == theme.selected_bg),
"unselected segment {idx} must not paint a selected_bg pill; \
fills were {:?}",
surface.fills,
);
}
assert!(
!surface.fills.iter().any(|(_, c)| *c == theme.hover_bg),
"no segment should paint hover_bg — nothing is hovered; \
fills were {:?}",
surface.fills,
);
}
#[test]
fn focused_row_paints_label_and_value_in_header_fg_not_foreground_or_muted_fg() {
let mut form = one_field_form(
"note",
FieldKind::ReadOnly {
value: StyledText::plain("hello"),
},
);
form.fields[0].label = StyledText::plain("Note");
form.focused_field = Some(WidgetId::new("note"));
form.has_focus = true;
let (surface, _flayout) = paint_recorded(&form);
let theme = Theme::default();
let (_, _, label_fg) = surface
.text_runs
.iter()
.find(|(_, text, _)| text == "Note")
.expect("label text run recorded");
assert_eq!(
*label_fg, theme.header_fg,
"focused row's label must use theme.header_fg (Tree's \
on-selection convention), not theme.foreground; text runs \
were {:?}",
surface.text_runs,
);
let (_, _, value_fg) = surface
.text_runs
.iter()
.find(|(_, text, _)| text == "hello")
.expect("value text run recorded");
assert_eq!(
*value_fg, theme.header_fg,
"a focused ReadOnly row's value must track the row's \
on-selection foreground, not an unconditional \
theme.muted_fg; text runs were {:?}",
surface.text_runs,
);
}
#[test]
fn toggle_paints_drawn_checkbox_not_bracket_glyph() {
let off = one_field_form("flag", FieldKind::Toggle { value: false });
let (surface, _) = paint_recorded(&off);
assert!(
!surface.text_runs.iter().any(|(_, t, _)| t.contains('[')),
"an unchecked toggle must not paint a bracket glyph; \
text runs were {:?}",
surface.text_runs,
);
assert_eq!(
surface.strokes.len(),
1,
"an unchecked toggle strokes exactly one box; strokes \
were {:?}",
surface.strokes,
);
let on = one_field_form("flag", FieldKind::Toggle { value: true });
let (surface, _) = paint_recorded(&on);
assert!(
!surface.text_runs.iter().any(|(_, t, _)| t.contains('[')),
"a checked toggle must not paint a bracket glyph either; \
text runs were {:?}",
surface.text_runs,
);
let theme = Theme::default();
assert!(
surface.fills.iter().any(|(_, c)| *c == theme.accent_fg),
"a checked toggle fills its box with accent_fg; fills \
were {:?}",
surface.fills,
);
}
#[test]
fn text_input_paints_stroked_box_not_bracket_glyphs() {
let form = one_field_form(
"name",
FieldKind::TextInput {
value: "hello".into(),
placeholder: String::new(),
cursor: None,
selection_anchor: None,
},
);
let (surface, _) = paint_recorded(&form);
assert!(
!surface
.text_runs
.iter()
.any(|(_, t, _)| t == "[" || t == "]"),
"no bracket glyph should be painted around the value; \
text runs were {:?}",
surface.text_runs,
);
assert!(
surface.text_runs.iter().any(|(_, t, _)| t == "hello"),
"the value itself must still be painted; text runs \
were {:?}",
surface.text_runs,
);
assert_eq!(
surface.strokes.len(),
1,
"exactly one stroked box should bound the input; strokes \
were {:?}",
surface.strokes,
);
}
#[test]
fn validation_message_does_not_overlap_label() {
let mut form = one_field_form(
"name",
FieldKind::TextInput {
value: String::new(),
placeholder: String::new(),
cursor: None,
selection_anchor: None,
},
);
form.fields[0].label = StyledText::plain("Name");
form.fields[0].validation = Some(ValidationState::Error("Required".to_string()));
let row_h = form_row_height(14.0);
let measure = form_field_measure(&form.fields[0], row_h, &FixedMeasure);
let flayout = form.layout(320.0, 160.0, |_| measure.clone());
let mut surface = RecordingSurface::default();
paint(
&form,
&flayout,
&mut surface,
&Theme::default(),
crate::Point::new(0.0, 0.0),
);
let (label_rect, _, _) = surface
.text_runs
.iter()
.find(|(_, t, _)| t == "Name")
.expect("label text run recorded");
let (msg_rect, _, _) = surface
.text_runs
.iter()
.find(|(_, t, _)| t == "Required")
.expect("validation message text run recorded");
let label_bottom = label_rect.y + label_rect.height;
assert!(
label_bottom <= msg_rect.y + 0.01,
"validation message (y={}) must start at or below the \
label's bottom edge (y={}); label was {:?}, message was {:?}",
msg_rect.y,
label_bottom,
label_rect,
msg_rect,
);
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(unused_imports)]
pub(crate) use native_surface_paint::paint;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum FormEvent {
ToggleChanged { id: WidgetId, value: bool },
TextInputChanged { id: WidgetId, value: String },
TextInputCommitted { id: WidgetId, value: String },
SliderChanged { id: WidgetId, value: f32 },
ColorChanged { id: WidgetId, value: crate::Color },
DropdownChanged { id: WidgetId, selected_idx: usize },
SegmentedControlChanged { id: WidgetId, selected_idx: usize },
DateChanged {
id: WidgetId,
value: crate::primitives::date_picker::CalendarDate,
},
FocusChanged { id: WidgetId },
ButtonClicked { id: WidgetId },
ToolbarButtonClicked {
field_id: WidgetId,
button_id: WidgetId,
},
KeyPressed { key: String, modifiers: Modifiers },
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Color;
#[test]
fn form_roundtrip_serde() {
let form = Form {
id: WidgetId::new("settings"),
fields: vec![
FormField {
id: WidgetId::new("header"),
label: StyledText::plain("Editor"),
kind: FieldKind::Label,
hint: StyledText::default(),
disabled: false,
validation: None,
},
FormField {
id: WidgetId::new("line-numbers"),
label: StyledText::plain("Show line numbers"),
kind: FieldKind::Toggle { value: true },
hint: StyledText::default(),
disabled: false,
validation: None,
},
FormField {
id: WidgetId::new("tabstop"),
label: StyledText::plain("Tab width"),
kind: FieldKind::TextInput {
value: "4".to_string(),
placeholder: "2".to_string(),
cursor: Some(1),
selection_anchor: None,
},
hint: StyledText::plain("Number of spaces per tab"),
disabled: false,
validation: None,
},
FormField {
id: WidgetId::new("save"),
label: StyledText::plain("Save settings"),
kind: FieldKind::Button,
hint: StyledText::default(),
disabled: false,
validation: None,
},
],
focused_field: Some(WidgetId::new("line-numbers")),
scroll_offset: 0,
has_focus: true,
};
let json = serde_json::to_string(&form).unwrap();
let back: Form = serde_json::from_str(&json).unwrap();
assert_eq!(form, back);
}
#[test]
fn form_event_roundtrip_serde() {
let events = vec![
FormEvent::ToggleChanged {
id: WidgetId::new("line-numbers"),
value: false,
},
FormEvent::TextInputChanged {
id: WidgetId::new("tabstop"),
value: "8".to_string(),
},
FormEvent::TextInputCommitted {
id: WidgetId::new("tabstop"),
value: "8".to_string(),
},
FormEvent::FocusChanged {
id: WidgetId::new("save"),
},
FormEvent::ButtonClicked {
id: WidgetId::new("save"),
},
FormEvent::KeyPressed {
key: "Escape".to_string(),
modifiers: Modifiers::default(),
},
];
for event in &events {
let json = serde_json::to_string(event).unwrap();
let back: FormEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, &back);
}
}
#[test]
fn form_slider_field_serde() {
let field = FormField {
id: WidgetId::new("font-size"),
label: StyledText::plain("Font size"),
kind: FieldKind::Slider {
value: 14.0,
min: 8.0,
max: 32.0,
step: 1.0,
},
hint: StyledText::plain("Editor font size in px"),
disabled: false,
validation: None,
};
let json = serde_json::to_string(&field).unwrap();
let back: FormField = serde_json::from_str(&json).unwrap();
assert_eq!(field, back);
}
#[test]
fn form_color_picker_field_serde() {
let field = FormField {
id: WidgetId::new("accent"),
label: StyledText::plain("Accent colour"),
kind: FieldKind::ColorPicker {
value: Color::rgb(0x78, 0xb4, 0xff),
},
hint: StyledText::default(),
disabled: false,
validation: None,
};
let json = serde_json::to_string(&field).unwrap();
let back: FormField = serde_json::from_str(&json).unwrap();
assert_eq!(field, back);
}
#[test]
fn form_dropdown_field_serde() {
let field = FormField {
id: WidgetId::new("theme"),
label: StyledText::plain("Theme"),
kind: FieldKind::Dropdown {
options: vec![
StyledText::plain("One Dark"),
StyledText::plain("Solarized Light"),
StyledText::plain("Monokai"),
],
selected_idx: 0,
},
hint: StyledText::default(),
disabled: false,
validation: None,
};
let json = serde_json::to_string(&field).unwrap();
let back: FormField = serde_json::from_str(&json).unwrap();
assert_eq!(field, back);
}
#[test]
fn form_slider_legacy_deserialize_with_default_step() {
let json = r#"{
"id": "x",
"label": {"spans":[{"text":"X","fg":null,"bg":null}]},
"kind": {"Slider": {"value": 5.0, "min": 0.0, "max": 10.0}},
"hint": {"spans":[]}
}"#;
let field: FormField = serde_json::from_str(json).unwrap();
match field.kind {
FieldKind::Slider { step, .. } => assert_eq!(step, 1.0),
_ => panic!("expected Slider"),
}
}
#[test]
fn form_toggle_group_serde() {
let field = FormField {
id: WidgetId::new("opts"),
label: StyledText::plain("Options"),
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,
},
],
},
hint: StyledText::default(),
disabled: false,
validation: None,
};
let json = serde_json::to_string(&field).unwrap();
let back: FormField = serde_json::from_str(&json).unwrap();
assert_eq!(field, back);
}
#[test]
fn form_button_row_serde() {
let field = FormField {
id: WidgetId::new("actions"),
label: StyledText::default(),
kind: FieldKind::ButtonRow {
buttons: vec![
ButtonRowItem {
id: WidgetId::new("next"),
label: "Find Next".into(),
disabled: false,
icon: None,
},
ButtonRowItem {
id: WidgetId::new("all"),
label: "Replace All".into(),
disabled: true,
icon: None,
},
],
},
hint: StyledText::default(),
disabled: false,
validation: None,
};
let json = serde_json::to_string(&field).unwrap();
let back: FormField = serde_json::from_str(&json).unwrap();
assert_eq!(field, back);
}
fn make_form_field(id: &str, label: &str, kind: FieldKind) -> FormField {
FormField {
id: WidgetId::new(id),
label: StyledText::plain(label),
kind,
hint: StyledText::default(),
disabled: false,
validation: None,
}
}
fn make_form(fields: Vec<FormField>, scroll: usize) -> Form {
Form {
id: WidgetId::new("f"),
fields,
focused_field: None,
scroll_offset: scroll,
has_focus: true,
}
}
#[test]
fn form_layout_empty() {
let f = make_form(vec![], 0);
let layout = f.layout(40.0, 20.0, |_| FormFieldMeasure::new(1.0));
assert_eq!(layout.visible_fields.len(), 0);
assert_eq!(layout.hit_test(5.0, 5.0), FormHit::Empty);
}
#[test]
fn form_layout_stacks_fields() {
let f = make_form(
vec![
make_form_field("header", "Editor", FieldKind::Label),
make_form_field("toggle1", "Line numbers", FieldKind::Toggle { value: true }),
make_form_field("btn", "Save", FieldKind::Button),
],
0,
);
let layout = f.layout(40.0, 10.0, |_| FormFieldMeasure::new(1.0));
assert_eq!(layout.visible_fields.len(), 3);
assert_eq!(layout.visible_fields[0].bounds.y, 0.0);
assert_eq!(layout.visible_fields[1].bounds.y, 1.0);
assert_eq!(layout.visible_fields[2].bounds.y, 2.0);
match layout.hit_test(10.0, 1.5) {
FormHit::Field(id) => assert_eq!(id.as_str(), "toggle1"),
_ => panic!("expected Field(toggle1)"),
}
}
#[test]
fn form_layout_hit_carries_widget_id_not_index() {
let f = make_form(
vec![
make_form_field("zebra", "Zebra", FieldKind::Button),
make_form_field("alpha", "Alpha", FieldKind::Button),
],
0,
);
let layout = f.layout(40.0, 5.0, |_| FormFieldMeasure::new(1.0));
match layout.hit_test(10.0, 0.5) {
FormHit::Field(id) => assert_eq!(id.as_str(), "zebra"),
_ => panic!(),
}
match layout.hit_test(10.0, 1.5) {
FormHit::Field(id) => assert_eq!(id.as_str(), "alpha"),
_ => panic!(),
}
}
#[test]
fn form_layout_scroll_offset_skips() {
let f = make_form(
(0..5)
.map(|i| make_form_field(&format!("f{i}"), &format!("F{i}"), FieldKind::Button))
.collect(),
2,
);
let layout = f.layout(40.0, 10.0, |_| FormFieldMeasure::new(1.0));
assert_eq!(layout.visible_fields[0].field_idx, 2);
assert_eq!(layout.visible_fields[0].id.as_str(), "f2");
}
#[test]
fn form_layout_varying_heights_by_kind() {
let fields = vec![
make_form_field("hdr", "Header", FieldKind::Label),
make_form_field(
"txt",
"Name",
FieldKind::TextInput {
value: "John".to_string(),
placeholder: String::new(),
cursor: Some(4),
selection_anchor: None,
},
),
];
let f = make_form(fields.clone(), 0);
let layout = f.layout(40.0, 10.0, |i| {
match fields[i].kind {
FieldKind::TextInput { .. } => FormFieldMeasure::new(2.0),
_ => FormFieldMeasure::new(1.0),
}
});
assert_eq!(layout.visible_fields[0].bounds.height, 1.0);
assert_eq!(layout.visible_fields[1].bounds.y, 1.0);
assert_eq!(layout.visible_fields[1].bounds.height, 2.0);
}
#[test]
fn text_input_cursor_and_selection_serde() {
let field = FormField {
id: WidgetId::new("name"),
label: StyledText::plain("Name"),
kind: FieldKind::TextInput {
value: "hello world".to_string(),
placeholder: String::new(),
cursor: Some(5),
selection_anchor: Some(0),
},
hint: StyledText::default(),
disabled: false,
validation: None,
};
let json = serde_json::to_string(&field).unwrap();
let back: FormField = serde_json::from_str(&json).unwrap();
assert_eq!(field, back);
let legacy = r#"{
"id": "legacy",
"label": {"spans":[{"text":"Legacy","fg":null,"bg":null}]},
"kind": {"TextInput": {"value": "x"}},
"hint": {"spans":[]}
}"#;
let parsed: FormField = serde_json::from_str(legacy).unwrap();
match parsed.kind {
FieldKind::TextInput {
cursor,
selection_anchor,
..
} => {
assert_eq!(cursor, None);
assert_eq!(selection_anchor, None);
}
other => panic!("unexpected kind: {:?}", other),
}
}
}