use super::cells;
use super::{Field, FormErrors};
use crate::event::Event;
use crate::geometry::{Rect, Size, clamp_u16};
use crate::keymap::Key;
use crate::text;
use crate::widget::{Axis, EventCx, Flex, Length, MeasureCx, Node, NodeMut, PaintCx, View, Widget};
const SUMMARY_GAP: u16 = 1;
pub struct Form<Msg> {
label_width: Option<u16>,
gap: u16,
summary: Vec<String>,
body: Vec<Node<Msg>>,
}
pub struct FormFields<'v, 'a, Msg> {
ui: &'v mut View<'a, Msg>,
label_width: Option<u16>,
}
impl<'a, Msg: 'static> FormFields<'_, 'a, Msg> {
pub fn field(&mut self, mut field: Field<Msg>, control: impl FnOnce(&mut View<'_, Msg>)) -> NodeMut<'_, Msg> {
if field.label_width.is_none() {
field.label_width = self.label_width;
}
self.ui.add_with(field, control).fill_width()
}
pub fn ui(&mut self) -> &mut View<'a, Msg> {
self.ui
}
}
impl<Msg: 'static> Form<Msg> {
#[must_use]
pub fn new() -> Self {
Self { label_width: None, gap: 1, summary: Vec::new(), body: Vec::new() }
}
#[must_use]
pub fn label_width(mut self, cells: u16) -> Self {
self.label_width = Some(cells);
self
}
#[must_use]
pub fn gap(mut self, rows: u16) -> Self {
self.gap = rows;
self
}
#[must_use]
pub fn summary(mut self, errors: &FormErrors) -> Self {
self.summary = errors.iter().map(|(_, message)| message.to_owned()).collect();
self
}
pub fn show<'v>(
mut self,
ui: &'v mut View<'_, Msg>,
build: impl FnOnce(&mut FormFields<'_, '_, Msg>),
) -> NodeMut<'v, Msg> {
let mut children = Vec::new();
build(&mut FormFields { ui: &mut ui.nested(&mut children), label_width: self.label_width });
let mut column = Node::new(Flex::new(Axis::Column, children), 0);
column.layout.gap = self.gap;
column.layout.width = Length::Fill(1);
self.body = vec![column];
ui.add(self).fill_width()
}
fn summary_height(&self, vertical_padding: u16) -> u16 {
if self.summary.is_empty() {
return 0;
}
let rows = clamp_u16(i32::try_from(self.summary.len()).unwrap_or(i32::MAX)).saturating_add(1);
cells::sum([rows, vertical_padding.saturating_mul(2), SUMMARY_GAP])
}
}
impl<Msg: 'static> Default for Form<Msg> {
fn default() -> Self {
Self::new()
}
}
impl<Msg: 'static> Widget<Msg> for Form<Msg> {
fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
let padding = cx.env().theme().style("form-summary", None, &[]).pair("padding").unwrap_or((1, 2));
let summary = self.summary_height(padding.0);
let body = self.body.first().map_or(Size::default(), |body| {
cx.measure_child(body, Size::new(available.width, available.height.saturating_sub(summary)))
});
let widest = self
.summary
.iter()
.map(|line| cells::sum([text::width(line), padding.1.saturating_mul(2), 3]))
.max()
.unwrap_or(0);
Size::new(body.width.max(widest), body.height.saturating_add(summary)).min(available)
}
fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
let style = cx.style("form-summary", None, &[]);
let padding = style.padding();
let summary_height = self.summary_height(padding.top);
if !self.summary.is_empty() {
let block = Rect::new(area.x, area.y, area.width, summary_height - SUMMARY_GAP);
cx.clear(block, style.text().bg.unwrap_or_else(|| cx.color("surface")));
let inner = block.inset(padding);
let marker = cx.env().icons().glyph("error").into_owned();
let indent = text::width(&marker).saturating_add(2);
let marker_style = cx.style("form-summary-marker", None, &[]).text();
cx.text(inner.x, inner.y, &marker, marker_style, inner.width);
let title = crate::t!("quvyta.form.summary", n = self.summary.len());
let budget = inner.width.saturating_sub(indent);
let title_style = cx.style("form-summary-title", None, &[]).text();
let shown = text::truncate(&title, budget).into_owned();
cx.text(inner.x + i32::from(indent), inner.y, &shown, title_style, budget);
let item_style = cx.style("form-summary-item", None, &[]).text();
for (row, message) in self.summary.iter().enumerate() {
let y = inner.y + 1 + i32::try_from(row).unwrap_or(i32::MAX);
let shown = text::truncate(message, budget).into_owned();
cx.text(inner.x + i32::from(indent), y, &shown, item_style, budget);
}
}
if let Some(body) = self.body.first() {
let rest = Rect::new(
area.x,
area.y + i32::from(summary_height),
area.width,
area.height.saturating_sub(summary_height),
);
cx.paint_child(body, rest);
}
}
fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
match event {
Event::Key(key) if key.is_plain(Key::Enter) => {
cx.focus_next();
true
}
_ => false,
}
}
fn children(&self) -> &[Node<Msg>] {
&self.body
}
fn children_mut(&mut self) -> &mut [Node<Msg>] {
&mut self.body
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{App, Command, Harness};
use crate::widgets::{Button, TextInput};
#[derive(Default)]
struct Signup {
name: String,
image: String,
errors: FormErrors,
submitted: bool,
label_width: Option<u16>,
summary: bool,
}
#[derive(Clone)]
enum Msg {
Name(String),
Image(String),
Submit,
}
impl Signup {
fn validate(&mut self) {
self.errors.check("name", self.name.chars().count() >= 3, "Use at least 3 characters");
self.errors.check("image", !self.image.is_empty(), "Choose an image");
}
}
impl App for Signup {
type Msg = Msg;
fn update(&mut self, msg: Msg) -> Command<Msg> {
match msg {
Msg::Name(name) => self.name = name,
Msg::Image(image) => self.image = image,
Msg::Submit => {
self.validate();
self.submitted = self.errors.is_empty();
return self.errors.focus_first();
}
}
Command::none()
}
fn view(&self, ui: &mut View<'_, Msg>) {
let mut form = Form::new();
if let Some(width) = self.label_width {
form = form.label_width(width);
}
if self.summary {
form = form.summary(&self.errors);
}
form.show(ui, |form| {
form.field(Field::new("Name").required(true).hint("Lowercase").error(self.errors.get("name")), |ui| {
ui.add(TextInput::new(&self.name).invalid(self.errors.has("name")).on_change(Msg::Name))
.width(Length::Cells(20))
.id("name");
});
form.field(Field::new("Image").error(self.errors.get("image")), |ui| {
ui.add(TextInput::new(&self.image).on_change(Msg::Image)).width(Length::Cells(20)).id("image");
});
});
ui.add(Button::new("Create").on_press(Msg::Submit)).id("create");
}
}
#[test]
fn labels_above_with_required_word_hint_and_error() {
let mut h = Harness::new(Signup::default(), 40, 8);
assert_eq!(h.screen(), "Name required\n ❯\nLowercase\n\nImage\n ❯\n Create\n\n");
h.press("tab").type_text("ab");
h.send(Msg::Submit);
assert_eq!(h.screen().lines().nth(2), Some("✕ Use at least 3 characters"));
assert_eq!(h.fg(2, 2), Some(h.env().theme().color("danger").expect("danger")));
assert!(h.is_focused("name"), "submit focuses the first problem");
}
#[test]
fn label_brightens_while_its_control_has_focus() {
let mut h = Harness::new(Signup::default(), 40, 8);
let idle = h.fg(0, 0);
h.press("tab");
assert_ne!(h.fg(0, 0), idle);
assert!(h.is_bold(0, 0));
}
#[test]
fn enter_moves_to_the_next_field_and_then_to_the_button() {
let mut h = Harness::new(Signup::default(), 40, 8);
h.press("tab").press("enter");
assert!(h.is_focused("image"));
h.press("enter");
assert!(h.is_focused("create"));
h.press("enter");
assert!(!h.app().submitted);
assert!(h.is_focused("name"));
}
#[test]
fn labels_beside_controls_fall_back_above_when_narrow() {
let app = Signup { label_width: Some(10), ..Signup::default() };
let mut h = Harness::new(app, 40, 6);
assert_eq!(h.screen(), "Name ❯\nrequired Lowercase\n\nImage ❯\n Create\n\n");
let app = Signup { label_width: Some(10), ..Signup::default() };
h = Harness::new(app, 24, 7);
assert!(h.screen().starts_with("Name required\n ❯\n"), "{}", h.screen());
}
struct Note {
placeholder: &'static str,
}
impl App for Note {
type Msg = Msg;
fn update(&mut self, _: Msg) -> Command<Msg> {
Command::none()
}
fn view(&self, ui: &mut View<'_, Msg>) {
Form::new().label_width(16).show(ui, |form| {
form.field(Field::new("Project"), |ui| {
ui.add(TextInput::new("").placeholder("qframe").on_change(Msg::Name));
});
form.field(Field::new("Note"), |ui| {
ui.add(TextInput::new("").placeholder(self.placeholder).on_change(Msg::Image));
});
});
}
}
#[test]
fn a_control_wider_than_the_room_beside_its_label_goes_under_it_and_keeps_its_placeholder() {
for placeholder in ["A note for this session", "Eine Notiz für diese Sitzung"] {
let h = Harness::new(Note { placeholder }, 40, 6);
let screen = h.screen();
assert!(!screen.contains('…') && screen.contains(placeholder), "{placeholder}: {screen}");
let lines: Vec<&str> = screen.lines().collect();
assert!(lines[0].starts_with("Project") && lines[0].contains("qframe"), "stays beside: {screen}");
let note = lines.iter().position(|line| line.starts_with("Note")).unwrap_or_else(|| panic!("{screen}"));
assert_eq!(lines[note].trim_end(), "Note", "the label has its own line: {screen}");
assert!(lines[note + 1].contains(placeholder), "the control is right under it: {screen}");
}
}
#[test]
fn a_required_word_too_wide_for_the_label_column_moves_under_the_control() {
for code in ["en", "de", "fr", "ru"] {
let app = Signup { label_width: Some(8), ..Signup::default() };
let mut h = Harness::new(app, 40, 7);
h.set_locale(code);
let screen = h.screen();
let required = h.env().i18n().translate("quvyta.form.required", &[]);
assert!(screen.contains(&required) && !screen.contains('…'), "{code}: {screen}");
let lines: Vec<&str> = screen.lines().collect();
let control = |line: &str| line.find('❯');
let image = lines.iter().position(|line| line.starts_with("Image")).unwrap_or_else(|| panic!("{screen}"));
assert_eq!(control(lines[0]), control(lines[image]), "the controls stay in one column: {code}: {screen}");
if code != "en" {
assert!(lines[1].trim_start().starts_with(required.as_str()), "under the control: {code}: {screen}");
assert!(lines[2].trim_start().starts_with("Lowercase"), "the hint follows: {code}: {screen}");
}
}
}
#[test]
fn summary_lists_problems_above_the_fields() {
let app = Signup { summary: true, ..Signup::default() };
let mut h = Harness::new(app, 40, 14);
assert!(h.screen().starts_with("Name"));
h.send(Msg::Submit);
let screen = h.screen();
assert!(screen.contains("✕ 2 fields need attention"), "{screen}");
assert!(screen.contains("Use at least 3 characters\n"), "{screen}");
}
}