Skip to main content

qframe/widgets/
field.rs

1//! Form fields: a label, a control, and a hint or an error.
2
3use crate::geometry::{Rect, Size, clamp_u16};
4use crate::text;
5use crate::theme::State;
6use crate::widget::{Axis, Container, Flex, Length, MeasureCx, Node, PaintCx, Widget};
7
8use super::cells;
9
10/// Cells between the label column and the control when labels sit beside controls.
11const LABEL_GAP: u16 = 2;
12
13/// The narrowest control a field keeps beside its label; below it the label moves above.
14const MIN_CONTROL: u16 = 16;
15
16/// A labelled control: the label, the control the application adds inside, and under it a
17/// faint hint or, when there is one, the error in the danger colour with a marker.
18///
19/// The field only lays things out. The application owns the value and its error, and marks the
20/// control itself as invalid (`TextInput::invalid`). The label brightens while the control has
21/// focus. Required fields show a faint word after the label; nothing is starred.
22///
23/// Labels sit above the control by default. With [`Field::label_width`] they take a column of
24/// that width beside the control whenever the field is wide enough, and move above it again on
25/// narrow screens.
26///
27/// Style keys: `field-label` (`fg`, `bold`) with states `focus` and `disabled`;
28/// `field-required`, `field-hint` and `field-error` (`fg`). The required word is
29/// `quvyta.form.required`; the error marker is the `error` icon.
30pub struct Field<Msg> {
31    label: String,
32    hint: Option<String>,
33    error: Option<String>,
34    required: bool,
35    disabled: bool,
36    pub(super) label_width: Option<u16>,
37    body: Vec<Node<Msg>>,
38}
39
40impl<Msg: 'static> Field<Msg> {
41    /// A field labelled `label`. Add its control with
42    /// [`View::add_with`](crate::widget::View::add_with) or [`FormFields::field`](super::FormFields::field).
43    #[must_use]
44    pub fn new(label: impl Into<String>) -> Self {
45        Self {
46            label: label.into(),
47            hint: None,
48            error: None,
49            required: false,
50            disabled: false,
51            label_width: None,
52            body: vec![Node::new(Flex::new(Axis::Column, Vec::new()), 0)],
53        }
54    }
55
56    /// Faint help under the control, shown while there is no error.
57    #[must_use]
58    pub fn hint(mut self, hint: impl Into<String>) -> Self {
59        self.hint = Some(hint.into());
60        self
61    }
62
63    /// The error under the control; `None` shows the hint instead. Pass
64    /// [`FormErrors::get`](super::FormErrors::get) straight in.
65    #[must_use]
66    pub fn error<S: Into<String>>(mut self, error: Option<S>) -> Self {
67        self.error = error.map(Into::into);
68        self
69    }
70
71    /// Shows the faint "required" word after the label.
72    #[must_use]
73    pub fn required(mut self, required: bool) -> Self {
74        self.required = required;
75        self
76    }
77
78    /// Greys the label out; disable the control as well.
79    #[must_use]
80    pub fn disabled(mut self, disabled: bool) -> Self {
81        self.disabled = disabled;
82        self
83    }
84
85    /// Puts the label in a column `cells` wide beside the control when the field is at least
86    /// that wide plus room for a control; otherwise the label stays above.
87    #[must_use]
88    pub fn label_width(mut self, cells: u16) -> Self {
89        self.label_width = Some(cells);
90        self
91    }
92
93    /// The label column width when the label fits beside the control in `width` cells.
94    fn beside(&self, width: u16) -> Option<u16> {
95        self.label_width.filter(|label| width >= cells::sum([*label, LABEL_GAP, MIN_CONTROL]))
96    }
97
98    /// The hint or error lines at `width`, and whether they are an error.
99    fn message_lines(&self, width: u16, marker_width: u16) -> (Vec<String>, bool) {
100        match (&self.error, &self.hint) {
101            (Some(error), _) => (text::wrap(error, width.saturating_sub(marker_width + 1)), true),
102            (None, Some(hint)) => (text::wrap(hint, width), false),
103            (None, None) => (Vec::new(), false),
104        }
105    }
106}
107
108impl<Msg: 'static> Container<Msg> for Field<Msg> {
109    fn set_children(&mut self, children: Vec<Node<Msg>>) {
110        let mut column = Node::new(Flex::new(Axis::Column, children), 0);
111        column.layout.width = Length::Fill(1);
112        self.body = vec![column];
113    }
114}
115
116/// Where the parts of a field go inside its area.
117struct Parts {
118    label: Rect,
119    required: Option<Rect>,
120    control: Rect,
121    message_x: i32,
122    message_width: u16,
123}
124
125fn required_word() -> String {
126    crate::t!("quvyta.form.required")
127}
128
129impl<Msg: 'static> Widget<Msg> for Field<Msg> {
130    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
131        let marker = text::width(&cx.env().icons().glyph("error"));
132        let label_width = text::width(&self.label);
133        let required = if self.required { text::width(&required_word()) } else { 0 };
134        let Some(body) = self.body.first() else {
135            return Size::default();
136        };
137        if let Some(column) = self.beside(available.width) {
138            let control_width = available.width - column - LABEL_GAP;
139            let control = cx.measure_child(body, Size::new(control_width, available.height));
140            let (lines, _) = self.message_lines(control_width, marker);
141            let label_rows = 1 + u16::from(self.required);
142            let right = control.height.saturating_add(clamp_u16(i32::try_from(lines.len()).unwrap_or(i32::MAX)));
143            return Size::new(available.width, label_rows.max(right)).min(available);
144        }
145        let control = cx.measure_child(body, Size::new(available.width, available.height.saturating_sub(1)));
146        let (lines, error) = self.message_lines(available.width, marker);
147        let widest_line = lines.iter().map(|line| text::width(line)).max().unwrap_or(0).saturating_add(if error {
148            marker.saturating_add(1)
149        } else {
150            0
151        });
152        let label_line = label_width.saturating_add(if self.required { required.saturating_add(2) } else { 0 });
153        let height = cells::sum([1, control.height, clamp_u16(i32::try_from(lines.len()).unwrap_or(i32::MAX))]);
154        Size::new(label_line.max(control.width).max(widest_line), height).min(available)
155    }
156
157    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
158        let Some(body) = self.body.first() else {
159            return;
160        };
161        let marker = cx.env().icons().glyph("error").into_owned();
162        let marker_width = text::width(&marker);
163        let required = required_word();
164        let parts = match self.beside(area.width) {
165            Some(column) => {
166                let control_x = area.x + i32::from(column + LABEL_GAP);
167                let control_width = area.width - column - LABEL_GAP;
168                let height = cx.measure_child(body, Size::new(control_width, area.height)).height;
169                Parts {
170                    label: Rect::new(area.x, area.y, column, 1),
171                    required: self.required.then(|| Rect::new(area.x, area.y + 1, column, 1)),
172                    control: Rect::new(control_x, area.y, control_width, height),
173                    message_x: control_x,
174                    message_width: control_width,
175                }
176            }
177            None => {
178                let label_width = text::width(&self.label).min(area.width);
179                let height = cx.measure_child(body, Size::new(area.width, area.height.saturating_sub(1))).height;
180                let required_x = area.x + i32::from(label_width) + 2;
181                Parts {
182                    label: Rect::new(area.x, area.y, label_width, 1),
183                    required: self
184                        .required
185                        .then(|| Rect::new(required_x, area.y, clamp_u16(area.right() - required_x), 1)),
186                    control: Rect::new(area.x, area.y + 1, area.width, height),
187                    message_x: area.x,
188                    message_width: area.width,
189                }
190            }
191        };
192        // The control is painted first so the label can tell whether focus is inside it.
193        cx.paint_child(body, parts.control);
194
195        let mut states = Vec::new();
196        if cx.has_focus_within() {
197            states.push(State::Focus);
198        }
199        if self.disabled {
200            states.push(State::Disabled);
201        }
202        let label_style = cx.style("field-label", None, &states).text();
203        let label = text::truncate(&self.label, parts.label.width).into_owned();
204        cx.text(parts.label.x, parts.label.y, &label, label_style, parts.label.width);
205        if let Some(rect) = parts.required {
206            let style = cx.style("field-required", None, &states).text();
207            let word = text::truncate(&required, rect.width).into_owned();
208            cx.text(rect.x, rect.y, &word, style, rect.width);
209        }
210
211        let (lines, error) = self.message_lines(parts.message_width, marker_width);
212        let top = parts.control.bottom();
213        let indent = if error { i32::from(marker_width) + 1 } else { 0 };
214        let style = cx.style(if error { "field-error" } else { "field-hint" }, None, &states).text();
215        if error {
216            cx.text(parts.message_x, top, &marker, style, marker_width);
217        }
218        let width = clamp_u16(i32::from(parts.message_width) - indent);
219        for (y, line) in (top..).zip(lines) {
220            cx.text(parts.message_x + indent, y, &line, style, width);
221        }
222    }
223
224    fn children(&self) -> &[Node<Msg>] {
225        &self.body
226    }
227
228    fn children_mut(&mut self) -> &mut [Node<Msg>] {
229        &mut self.body
230    }
231}