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/// The width a control is measured in to learn how wide it wants to be. One that answers with all
17/// of it fills whatever it is given.
18const UNBOUNDED: u16 = 4096;
19
20/// A labelled control: the label, the control the application adds inside, and under it a
21/// faint hint or, when there is one, the error in the danger colour with a marker.
22///
23/// The field only lays things out. The application owns the value and its error, and marks the
24/// control itself as invalid (`TextInput::invalid`). The label brightens while the control has
25/// focus. Required fields show a faint word after the label; nothing is starred.
26///
27/// Labels sit above the control by default. With [`Field::label_width`] they take a column of
28/// that width beside the control whenever the field is wide enough, and move above it again on
29/// narrow screens. A control that asks for more than the room beside the label (an input with a
30/// long placeholder) puts its label above as well and takes the whole row instead of being cut.
31/// The column is at least as wide as the required word of the active language.
32///
33/// Style keys: `field-label` (`fg`, `bold`) with states `focus` and `disabled`, and variant
34/// `value` for a [value](Field::value) field;
35/// `field-required`, `field-hint` and `field-error` (`fg`). The required word is
36/// `quvyta.form.required`; the error marker is the `error` icon.
37pub struct Field<Msg> {
38    label: String,
39    hint: Option<String>,
40    error: Option<String>,
41    required: bool,
42    disabled: bool,
43    value: bool,
44    pub(super) label_width: Option<u16>,
45    body: Vec<Node<Msg>>,
46}
47
48impl<Msg: 'static> Field<Msg> {
49    /// A field labelled `label`. Add its control with
50    /// [`View::add_with`](crate::widget::View::add_with) or [`FormFields::field`](super::FormFields::field).
51    #[must_use]
52    pub fn new(label: impl Into<String>) -> Self {
53        Self {
54            label: label.into(),
55            hint: None,
56            error: None,
57            required: false,
58            disabled: false,
59            value: false,
60            label_width: None,
61            body: vec![Node::new(Flex::new(Axis::Column, Vec::new()), 0)],
62        }
63    }
64
65    /// Shows `text` where the control would be: a value to read, not to change, such as the
66    /// folder a program installs into, lined up with the fields around it. The label is faint,
67    /// since there is nothing to fill in, and anything added inside is replaced by the value.
68    #[must_use]
69    pub fn value(mut self, text: impl Into<String>) -> Self {
70        self.value = true;
71        self.body = vec![Node::new(Flex::new(Axis::Column, vec![Node::new(super::Text::new(text), 0)]), 0)];
72        self
73    }
74
75    /// Faint help under the control, shown while there is no error.
76    #[must_use]
77    pub fn hint(mut self, hint: impl Into<String>) -> Self {
78        self.hint = Some(hint.into());
79        self
80    }
81
82    /// The error under the control; `None` shows the hint instead. Pass
83    /// [`FormErrors::get`](super::FormErrors::get) straight in.
84    #[must_use]
85    pub fn error<S: Into<String>>(mut self, error: Option<S>) -> Self {
86        self.error = error.map(Into::into);
87        self
88    }
89
90    /// Shows the faint "required" word after the label.
91    #[must_use]
92    pub fn required(mut self, required: bool) -> Self {
93        self.required = required;
94        self
95    }
96
97    /// Greys the label out; disable the control as well.
98    #[must_use]
99    pub fn disabled(mut self, disabled: bool) -> Self {
100        self.disabled = disabled;
101        self
102    }
103
104    /// Puts the label in a column `cells` wide beside the control when the field is at least
105    /// that wide plus room for a control; otherwise the label stays above.
106    #[must_use]
107    pub fn label_width(mut self, cells: u16) -> Self {
108        self.label_width = Some(cells);
109        self
110    }
111
112    /// The label column width when the label fits beside the control in `width` cells.
113    ///
114    /// `natural` is the width the control asks for with no limit. A control wider than the room
115    /// beside the label would be cut there, so its label goes above and it gets the whole row; a
116    /// control that takes whatever it is given stays beside.
117    fn beside(&self, width: u16, natural: u16) -> Option<u16> {
118        self.label_width.filter(|label| {
119            let room = width.saturating_sub(cells::sum([*label, LABEL_GAP]));
120            room >= MIN_CONTROL && (natural <= room || natural >= UNBOUNDED)
121        })
122    }
123
124    /// Whether the required word, too wide for the label column `column`, goes under the control
125    /// instead, on its own line before the hint. Growing the column would take room from every
126    /// control of the form, and cutting the word would lose it.
127    fn required_under_control(&self, column: u16, word: &str) -> bool {
128        self.required && text::width(word) > column
129    }
130
131    /// The hint or error lines at `width`, and whether they are an error.
132    fn message_lines(&self, width: u16, marker_width: u16) -> (Vec<String>, bool) {
133        match (&self.error, &self.hint) {
134            (Some(error), _) => (text::wrap(error, width.saturating_sub(marker_width + 1)), true),
135            (None, Some(hint)) => (text::wrap(hint, width), false),
136            (None, None) => (Vec::new(), false),
137        }
138    }
139}
140
141impl<Msg: 'static> Container<Msg> for Field<Msg> {
142    fn set_children(&mut self, children: Vec<Node<Msg>>) {
143        // A value field shows its value; a control added to it has no place.
144        if self.value {
145            return;
146        }
147        let mut column = Node::new(Flex::new(Axis::Column, children), 0);
148        column.layout.width = Length::Fill(1);
149        self.body = vec![column];
150    }
151}
152
153/// Where the parts of a field go inside its area.
154struct Parts {
155    label: Rect,
156    required: Option<Rect>,
157    control: Rect,
158    message_x: i32,
159    message_width: u16,
160}
161
162fn required_word() -> String {
163    crate::t!("quvyta.form.required")
164}
165
166impl<Msg: 'static> Widget<Msg> for Field<Msg> {
167    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
168        let marker = text::width(&cx.env().icons().glyph("error"));
169        let label_width = text::width(&self.label);
170        let required = if self.required { text::width(&required_word()) } else { 0 };
171        let Some(body) = self.body.first() else {
172            return Size::default();
173        };
174        let natural = cx.measure_child(body, Size::new(UNBOUNDED, available.height)).width;
175        if let Some(column) = self.beside(available.width, natural) {
176            let control_width = available.width - column - LABEL_GAP;
177            let control = cx.measure_child(body, Size::new(control_width, available.height));
178            let (lines, _) = self.message_lines(control_width, marker);
179            let below = self.required_under_control(column, &required_word());
180            let label_rows = 1 + u16::from(self.required && !below);
181            let messages = clamp_u16(i32::try_from(lines.len()).unwrap_or(i32::MAX)).saturating_add(u16::from(below));
182            let right = control.height.saturating_add(messages);
183            return Size::new(available.width, label_rows.max(right)).min(available);
184        }
185        let control = cx.measure_child(body, Size::new(available.width, available.height.saturating_sub(1)));
186        let (lines, error) = self.message_lines(available.width, marker);
187        let widest_line = lines.iter().map(|line| text::width(line)).max().unwrap_or(0).saturating_add(if error {
188            marker.saturating_add(1)
189        } else {
190            0
191        });
192        let label_line = label_width.saturating_add(if self.required { required.saturating_add(2) } else { 0 });
193        let height = cells::sum([1, control.height, clamp_u16(i32::try_from(lines.len()).unwrap_or(i32::MAX))]);
194        Size::new(label_line.max(control.width).max(widest_line), height).min(available)
195    }
196
197    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
198        let Some(body) = self.body.first() else {
199            return;
200        };
201        let marker = cx.env().icons().glyph("error").into_owned();
202        let marker_width = text::width(&marker);
203        let required = required_word();
204        let natural = cx.measure_child(body, Size::new(UNBOUNDED, area.height)).width;
205        let parts = match self.beside(area.width, natural) {
206            Some(column) => {
207                let control_x = area.x + i32::from(column + LABEL_GAP);
208                let control_width = area.width - column - LABEL_GAP;
209                let height = cx.measure_child(body, Size::new(control_width, area.height)).height;
210                let required = if self.required_under_control(column, &required) {
211                    Rect::new(control_x, area.y + i32::from(height), control_width, 1)
212                } else {
213                    Rect::new(area.x, area.y + 1, column, 1)
214                };
215                Parts {
216                    label: Rect::new(area.x, area.y, column, 1),
217                    required: self.required.then_some(required),
218                    control: Rect::new(control_x, area.y, control_width, height),
219                    message_x: control_x,
220                    message_width: control_width,
221                }
222            }
223            None => {
224                let label_width = text::width(&self.label).min(area.width);
225                let height = cx.measure_child(body, Size::new(area.width, area.height.saturating_sub(1))).height;
226                let required_x = area.x + i32::from(label_width) + 2;
227                Parts {
228                    label: Rect::new(area.x, area.y, label_width, 1),
229                    required: self
230                        .required
231                        .then(|| Rect::new(required_x, area.y, clamp_u16(area.right() - required_x), 1)),
232                    control: Rect::new(area.x, area.y + 1, area.width, height),
233                    message_x: area.x,
234                    message_width: area.width,
235                }
236            }
237        };
238        // The control is painted first so the label can tell whether focus is inside it.
239        cx.paint_child(body, parts.control);
240
241        let mut states = Vec::new();
242        if cx.has_focus_within() {
243            states.push(State::Focus);
244        }
245        if self.disabled {
246            states.push(State::Disabled);
247        }
248        let label_style = cx.style("field-label", self.value.then_some("value"), &states).text();
249        let label = text::truncate(&self.label, parts.label.width).into_owned();
250        cx.text(parts.label.x, parts.label.y, &label, label_style, parts.label.width);
251        if let Some(rect) = parts.required {
252            let style = cx.style("field-required", None, &states).text();
253            let word = text::truncate(&required, rect.width).into_owned();
254            cx.text(rect.x, rect.y, &word, style, rect.width);
255        }
256
257        let (lines, error) = self.message_lines(parts.message_width, marker_width);
258        // The hint starts under the required word when the word sits under the control.
259        let top = match parts.required {
260            Some(rect) if rect.x == parts.control.x && rect.y >= parts.control.bottom() => rect.bottom(),
261            _ => parts.control.bottom(),
262        };
263        let indent = if error { i32::from(marker_width) + 1 } else { 0 };
264        let style = cx.style(if error { "field-error" } else { "field-hint" }, None, &states).text();
265        if error {
266            cx.text(parts.message_x, top, &marker, style, marker_width);
267        }
268        let width = clamp_u16(i32::from(parts.message_width) - indent);
269        for (y, line) in (top..).zip(lines) {
270            cx.text(parts.message_x + indent, y, &line, style, width);
271        }
272    }
273
274    fn children(&self) -> &[Node<Msg>] {
275        &self.body
276    }
277
278    fn children_mut(&mut self) -> &mut [Node<Msg>] {
279        &mut self.body
280    }
281}