Skip to main content

ravenrs_widgets/
editors.rs

1//! Editors for values a plain text field cannot hold well: long text, numbers,
2//! masked text and times.
3
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, CursorIcon, Insets, Key, Modifiers, Point, Quad, Rect, Role};
8use ravenrs_layout::{Align, Axis, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle};
11
12use crate::context::{BuildCx, DragPhase, NodeId, Paint, Placement, QuadPaint, Semantics};
13use crate::date::Date;
14use crate::overlay::Options;
15use crate::text_field::{ClipboardAccess, Cursor, TextField, byte_index, edit, index_at, insert, word_at};
16use crate::{ButtonSize, DatePicker, IconButton, IconName, VStack, View, util};
17
18// ---------------------------------------------------------------- TextArea
19
20/// Lines shown by default in a [`TextArea`].
21const DEFAULT_ROWS: usize = 4;
22/// Width assumed until the first layout says how wide a [`TextArea`] is.
23const FIRST_GUESS_WIDTH: f32 = 320.0;
24const AREA_PADDING: f32 = 12.0;
25/// What the area's border takes at its edge, focused or not, kept clear of the gutter.
26const BORDER_ROOM: f32 = 1.5;
27
28/// Breaks `text` into lines that fit `width`, as ranges of char indices. Line
29/// breaks in the text always break, the break itself belongs to no line, and
30/// long lines break after a space, or anywhere in a word wider than `width`.
31pub(crate) fn wrap_lines(
32    text: &str,
33    width: f32,
34    mut measure: impl FnMut(&str) -> f32,
35) -> Vec<(usize, usize)> {
36    let chars: Vec<char> = text.chars().collect();
37    let mut lines = Vec::new();
38    let mut paragraph = 0;
39    loop {
40        let end = chars[paragraph..].iter().position(|c| *c == '\n').map_or(chars.len(), |n| paragraph + n);
41        wrap_paragraph(&chars, paragraph, end, width, &mut measure, &mut lines);
42        if end == chars.len() {
43            return lines;
44        }
45        paragraph = end + 1;
46    }
47}
48
49fn wrap_paragraph(
50    chars: &[char],
51    start: usize,
52    end: usize,
53    width: f32,
54    measure: &mut impl FnMut(&str) -> f32,
55    lines: &mut Vec<(usize, usize)>,
56) {
57    let piece = |a: usize, b: usize| chars[a..b].iter().collect::<String>();
58    let mut line = start;
59    let mut used = 0.0;
60    let mut at = start;
61    while at < end {
62        // A word with the spaces after it.
63        let mut next = at;
64        while next < end && !chars[next].is_whitespace() {
65            next += 1;
66        }
67        while next < end && chars[next].is_whitespace() {
68            next += 1;
69        }
70        let w = measure(&piece(at, next));
71        if used + w > width && line < at {
72            lines.push((line, at));
73            line = at;
74            used = 0.0;
75        }
76        if w > width {
77            // Wider than a whole line: cut the word where it stops fitting.
78            let mut from = at;
79            loop {
80                let mut to = from + 1;
81                while to < next && measure(&piece(from, to + 1)) <= width {
82                    to += 1;
83                }
84                if to == next {
85                    line = from;
86                    used = measure(&piece(from, to));
87                    break;
88                }
89                lines.push((from, to));
90                from = to;
91            }
92        } else {
93            used += w;
94        }
95        at = next;
96    }
97    lines.push((line, end));
98}
99
100/// The line holding char index `caret`. A caret at a soft break belongs to the
101/// next line, where typing would continue.
102fn line_of(lines: &[(usize, usize)], caret: usize) -> usize {
103    lines.iter().rposition(|(start, _)| *start <= caret).unwrap_or(0)
104}
105
106/// Colored stretches of a text, as `(from, to, color)` in char indices, sorted and apart.
107pub(crate) type Highlighter = Rc<dyn Fn(&str) -> Vec<(usize, usize, Semantic)>>;
108
109/// A line a code area marks: its index from 0, the color, and the char columns to underline.
110pub(crate) type LineMark = (usize, Semantic, Option<(usize, usize)>);
111
112/// How a text area shows code, set by [`crate::CodeEditor`]: a monospaced font, no
113/// wrapping, line numbers, colors and marks, and keys for indenting.
114#[derive(Clone)]
115pub(crate) struct CodeLook {
116    pub(crate) highlight: Option<Highlighter>,
117    pub(crate) line_numbers: bool,
118    pub(crate) marks: Rc<Vec<LineMark>>,
119    /// Spaces Tab inserts and Enter carries on; Ctrl or Cmd with Enter is left to shortcuts.
120    pub(crate) indent: usize,
121    /// Takes the height its parent gives instead of a number of rows.
122    pub(crate) fill: bool,
123    pub(crate) background: Semantic,
124}
125
126/// A marked line as drawn: its top, color, and the span to underline in pixels.
127type DrawnMark = (f32, ravenrs_core::Color, Option<(f32, f32)>);
128
129/// Room between the line numbers and the code.
130const GUTTER_GAP: f32 = 16.0;
131
132/// A multi-line text input bound to a `String`. Enter starts a new line, the
133/// arrows move between lines, and it scrolls past [`Self::rows`] lines.
134///
135/// ```
136/// use ravenrs_reactive::State;
137/// use ravenrs_widgets::{TextArea, Ui};
138///
139/// let notes = State::new(String::new());
140/// let field = TextArea::new(notes.binding()).placeholder("Notes").rows(6);
141/// let mut ui = Ui::new(field, ravenrs_style::Theme::raven_dark());
142/// ui.update();
143/// ```
144#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
145pub struct TextArea {
146    value: Binding<String>,
147    placeholder: String,
148    rows: usize,
149    grows: Option<(usize, usize)>,
150    width: Option<f32>,
151    state: util::EditorState,
152    /// Caret and selection kept by whoever built the area, so it can insert at the caret.
153    cursor: Option<State<Cursor>>,
154    code: Option<CodeLook>,
155}
156
157impl fmt::Debug for TextArea {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        f.debug_struct("TextArea").field("rows", &self.rows).finish_non_exhaustive()
160    }
161}
162
163#[cfg_attr(feature = "live", ravenrs_live::expose)]
164impl TextArea {
165    /// An empty-placeholder area bound to `value`.
166    pub fn new(value: Binding<String>) -> Self {
167        Self {
168            value,
169            placeholder: String::new(),
170            rows: DEFAULT_ROWS,
171            grows: None,
172            width: None,
173            state: util::EditorState::default(),
174            cursor: None,
175            code: None,
176        }
177    }
178
179    /// Shows the text as code. Only `CodeEditor`, of the `code` feature, asks for it.
180    #[cfg(feature = "code")]
181    pub(crate) fn code(mut self, look: CodeLook) -> Self {
182        self.code = Some(look);
183        self
184    }
185
186    /// Keeps the caret and selection in `cursor`, for a component that inserts text where
187    /// the caret is.
188    pub(crate) fn cursor(mut self, cursor: State<Cursor>) -> Self {
189        self.cursor = Some(cursor);
190        self
191    }
192
193    /// Muted text shown while the value is empty.
194    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
195        self.placeholder = text.into();
196        self
197    }
198
199    /// How many lines are visible; more than that scroll. 4 by default.
200    pub fn rows(mut self, rows: usize) -> Self {
201        self.rows = rows.max(1);
202        self
203    }
204
205    /// Grows with the text from `min` to `max` lines, and scrolls past that, as a message
206    /// box does. It replaces the fixed height of [`Self::rows`].
207    pub fn grows(mut self, min: usize, max: usize) -> Self {
208        let min = min.max(1);
209        self.grows = Some((min, max.max(min)));
210        self
211    }
212
213    /// Fixed width in logical pixels; by default the area stretches.
214    pub fn width(mut self, width: f32) -> Self {
215        self.width = Some(width);
216        self
217    }
218
219    /// Turns the area off: it fades, leaves the tab order and ignores input.
220    pub fn disabled(mut self, disabled: bool) -> Self {
221        self.state.disabled = disabled;
222        self
223    }
224
225    /// Shows the text without letting it change; it can still be focused, selected and copied.
226    pub fn read_only(mut self, read_only: bool) -> Self {
227        self.state.read_only = read_only;
228        self
229    }
230}
231
232/// Moves the caret to the same column of another line.
233fn move_lines(lines: &[(usize, usize)], c: &mut Cursor, down: bool, extend: bool) {
234    let here = line_of(lines, c.caret);
235    let column = c.caret - lines[here].0;
236    let there = if down { here + 1 } else { here.wrapping_sub(1) };
237    let to = match lines.get(there) {
238        Some((start, end)) => (start + column).min(*end),
239        None if down => lines[here].1,
240        None => lines[here].0,
241    };
242    c.move_to(to, extend);
243}
244
245/// Applies a key to a text area. Returns false for keys it does not handle.
246fn edit_area(
247    text: &mut String,
248    c: &mut Cursor,
249    lines: &[(usize, usize)],
250    key: &Key,
251    modifiers: Modifiers,
252    access: ClipboardAccess<'_>,
253    indent: usize,
254) -> bool {
255    let len = text.chars().count();
256    c.caret = c.caret.min(len);
257    let plain = !modifiers.ctrl && !modifiers.meta;
258    match key {
259        Key::Enter if indent > 0 && !plain => return false,
260        Key::Enter if indent > 0 => {
261            let chars: Vec<char> = text.chars().collect();
262            let start = lines[line_of(lines, c.caret)].0;
263            let before = &chars[start..c.caret.max(start)];
264            let mut spaces = before.iter().take_while(|ch| **ch == ' ').count();
265            if before
266                .iter()
267                .rev()
268                .find(|ch| !ch.is_whitespace())
269                .is_some_and(|ch| matches!(ch, '{' | '(' | '['))
270            {
271                spaces += indent;
272            }
273            insert(text, c, &format!("\n{}", " ".repeat(spaces)));
274        }
275        Key::Tab if indent > 0 && modifiers.shift => {
276            let start = lines[line_of(lines, c.caret)].0;
277            let leading = text.chars().skip(start).take(indent).take_while(|ch| *ch == ' ').count();
278            if leading > 0 {
279                let from = byte_index(text, start);
280                text.replace_range(from..byte_index(text, start + leading), "");
281                c.caret = c.caret.saturating_sub(leading).max(start);
282                c.anchor = None;
283            }
284        }
285        Key::Tab if indent > 0 => {
286            let column = c.caret - lines[line_of(lines, c.caret)].0;
287            insert(text, c, &" ".repeat(indent - column % indent));
288        }
289        Key::Enter => insert(text, c, "\n"),
290        Key::ArrowUp | Key::ArrowDown if plain => {
291            move_lines(lines, c, *key == Key::ArrowDown, modifiers.shift)
292        }
293        Key::Home if plain => c.move_to(lines[line_of(lines, c.caret)].0, modifiers.shift),
294        Key::End if plain => c.move_to(lines[line_of(lines, c.caret)].1, modifiers.shift),
295        Key::Character(s) if plain && s.contains(['\n', '\r']) => {
296            insert(text, c, &s.replace("\r\n", "\n").replace('\r', "\n"));
297        }
298        _ => return edit(text, c, key, modifiers, Some(access)),
299    }
300    true
301}
302
303impl View for TextArea {
304    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
305        let theme = *cx.theme();
306        let state = self.state;
307        let own: State<Cursor> = cx.use_state(|| Cursor::at(usize::MAX));
308        let cursor = self.cursor.clone().unwrap_or(own);
309        let followed: State<usize> = cx.use_state(|| usize::MAX);
310        let focused = cx.is_focused() && !state.disabled;
311        let hovered = cx.is_hovered() && !state.disabled;
312        let text = self.value.get();
313        let chars: Vec<char> = text.chars().collect();
314        let code = self.code.clone();
315        let mut attrs = util::attrs(&theme, TextStyle::Body, None);
316        if code.is_some() {
317            attrs.family = Some("monospace".to_owned());
318            attrs.size -= 1.0;
319        }
320        let attrs = attrs;
321        let line_height = attrs.line_height;
322
323        // The width of the last layout, known from the second build on.
324        let viewport = cx.scroll_viewport();
325        let outer = self.width.or(viewport.map(|v| v.width)).unwrap_or(FIRST_GUESS_WIDTH);
326        let inner = (outer - 2.0 * AREA_PADDING).max(1.0);
327        let lines = if code.is_some() {
328            // Code does not wrap: a line is what lies between line breaks.
329            wrap_lines(&text, f32::INFINITY, |_| 0.0)
330        } else {
331            let attrs = attrs.clone();
332            wrap_lines(&text, inner, |s| cx.measure_text(s, &attrs).width)
333        };
334        let lines = Rc::new(lines);
335        let fill = code.as_ref().is_some_and(|c| c.fill);
336        let rows_shown = if fill {
337            viewport.map_or(self.rows, |v| ((v.height - AREA_PADDING) / line_height).ceil().max(1.0) as usize)
338        } else {
339            self.grows.map_or(self.rows, |(min, max)| lines.len().clamp(min, max))
340        };
341        let height = rows_shown as f32 * line_height + AREA_PADDING;
342        // In a monospaced font a character's place is its column times one advance, which
343        // also counts the spaces a measured piece would leave out at its end.
344        let advance = code.is_some().then(|| cx.measure_text("0000000000", &attrs).width / 10.0);
345        // The line numbers' width, and the room they keep beside the code.
346        let gutter_width = code.as_ref().filter(|c| c.line_numbers).map(|_| {
347            let digits = lines.len().max(1).to_string().len().max(2);
348            digits as f32 * cx.measure_text("0", &attrs).width
349        });
350        let gutter_room = gutter_width.map_or(0.0, |w| w + GUTTER_GAP);
351        let cur = cursor.get();
352        let caret = cur.caret.min(chars.len());
353        let caret_line = line_of(&lines, caret);
354
355        // Keep the caret in view after it moves, and leave the wheel alone otherwise.
356        let scrolled = cx.scroll_offset();
357        let mut offset = scrolled.y;
358        // Code scrolls sideways too, since it does not wrap.
359        let mut sideways = if code.is_some() { scrolled.x } else { 0.0 };
360        // Everything fits again once text is deleted, and nothing is left to scroll.
361        if self.grows.is_some() && lines.len() <= rows_shown && offset > 0.0 {
362            offset = 0.0;
363            cx.set_scroll_offset(Point::new(sideways, 0.0));
364        }
365        if focused && followed.get() != caret {
366            followed.set_if_changed(caret);
367            let top = caret_line as f32 * line_height;
368            // While it grows, the last layout's height is already out of date.
369            let last_height = viewport.filter(|_| self.grows.is_none()).map_or(height, |v| v.height);
370            let visible = last_height - AREA_PADDING;
371            let next = if top < offset {
372                top
373            } else if top + line_height > offset + visible {
374                top + line_height - visible
375            } else {
376                offset
377            };
378            // Sideways, the caret's column stays in view, between the line numbers and the edge.
379            let across = match (advance, viewport) {
380                (Some(advance), Some(v)) => {
381                    let column = caret - lines[caret_line].0;
382                    let x = column as f32 * advance;
383                    let visible = (v.width - 2.0 * AREA_PADDING - gutter_room).max(advance);
384                    if x < sideways {
385                        (x - advance * 4.0).max(0.0)
386                    } else if x + advance > sideways + visible {
387                        x + advance * 4.0 - visible
388                    } else {
389                        sideways
390                    }
391                }
392                _ => sideways,
393            };
394            if next != offset || across != sideways {
395                offset = next;
396                sideways = across;
397                cx.set_scroll_offset(Point::new(across, next));
398            }
399        }
400
401        // Only the lines in view are built; spacers hold the place of the rest.
402        let first = ((offset / line_height).floor() as usize).min(lines.len().saturating_sub(1));
403        let last = (first + rows_shown + 2).min(lines.len());
404        let piece = |a: usize, b: usize| chars[a..b].iter().collect::<String>();
405        let mut boundaries: Vec<(usize, Vec<f32>)> = Vec::with_capacity(last - first);
406        let mut rows = Vec::with_capacity(last - first + 2);
407        if first > 0 {
408            rows.push(cx.container(
409                LayoutStyle { height: Some(first as f32 * line_height), ..LayoutStyle::leaf() },
410                &[],
411                Paint::None,
412            ));
413        }
414        if text.is_empty() {
415            let color = state.ink(theme.color(Semantic::InkMuted));
416            rows.push(cx.text(&self.placeholder, &attrs, color, LayoutStyle::leaf()));
417            boundaries.push((0, vec![0.0]));
418        } else {
419            let spans =
420                code.as_ref().and_then(|c| c.highlight.as_ref()).map(|h| h(&text)).unwrap_or_default();
421            for &(a, b) in &lines[first..last] {
422                let shown = piece(a, b);
423                let color = state.ink(theme.color(Semantic::Ink));
424                let line = LayoutStyle { height: Some(line_height), ..LayoutStyle::leaf() };
425                let xs: Vec<f32> = match advance {
426                    Some(advance) => (a..=b).map(|i| (i - a) as f32 * advance).collect(),
427                    None => (a..=b).map(|i| cx.measure_text(&piece(a, i), &attrs).width).collect(),
428                };
429                rows.push(if shown.is_empty() {
430                    cx.container(line, &[], Paint::None)
431                } else if spans.is_empty() {
432                    cx.text(&shown, &attrs, color, line)
433                } else {
434                    // Each colored stretch sits where its first character falls.
435                    let mut pieces = Vec::new();
436                    let mut at = a;
437                    let mut push = |cx: &mut BuildCx<'_>, from: usize, to: usize, semantic: Semantic| {
438                        let words = piece(from, to);
439                        if words.trim().is_empty() {
440                            return;
441                        }
442                        let place =
443                            LayoutStyle { absolute: Some((xs[from - a], 0.0)), ..LayoutStyle::leaf() };
444                        pieces.push(cx.text(&words, &attrs, state.ink(theme.color(semantic)), place));
445                    };
446                    for &(from, to, semantic) in spans.iter().filter(|(from, to, _)| *to > a && *from < b) {
447                        let (from, to) = (from.max(a), to.min(b));
448                        if from > at {
449                            push(cx, at, from, Semantic::Ink);
450                        }
451                        push(cx, from, to, semantic);
452                        at = to;
453                    }
454                    if at < b {
455                        push(cx, at, b, Semantic::Ink);
456                    }
457                    cx.container(line, &pieces, Paint::None)
458                });
459                boundaries.push((a, xs));
460            }
461        }
462        if last < lines.len() {
463            rows.push(cx.container(
464                LayoutStyle {
465                    height: Some((lines.len() - last) as f32 * line_height),
466                    ..LayoutStyle::leaf()
467                },
468                &[],
469                Paint::None,
470            ));
471        }
472        let boundaries = Rc::new(boundaries);
473
474        // The selection on each visible line, and the caret, under the text.
475        let x_at = |i: usize, line: usize| -> f32 {
476            let Some((start, xs)) = boundaries.get(line.wrapping_sub(first)) else { return 0.0 };
477            xs.get(i.saturating_sub(*start)).copied().unwrap_or(0.0)
478        };
479        let selected: Vec<(f32, f32, f32)> = cur
480            .selection()
481            .map(|(a, b)| {
482                (first..last)
483                    .filter(|&n| lines[n].0 <= b && a <= lines[n].1)
484                    .map(|n| {
485                        let (from, to) = (a.max(lines[n].0), b.min(lines[n].1));
486                        (n as f32 * line_height, x_at(from, n), x_at(to, n).max(x_at(from, n) + 4.0))
487                    })
488                    .collect()
489            })
490            .unwrap_or_default();
491        let caret_spot = (focused && cur.selection().is_none() && state.accepts_input())
492            .then(|| (x_at(caret, caret_line), caret_line as f32 * line_height));
493        let accent = theme.color(Semantic::Accent);
494        let highlight = accent.with_alpha(if focused { 0.32 } else { 0.16 });
495        // Marked lines: a band across the line, and a wavy-free underline under the columns.
496        let marks: Vec<DrawnMark> = code
497            .as_ref()
498            .map(|c| {
499                c.marks
500                    .iter()
501                    .filter(|(n, _, _)| (first..last).contains(n))
502                    .map(|&(n, semantic, columns)| {
503                        let under = columns.map(|(from, to)| {
504                            let start = lines[n].0;
505                            let (from, to) = ((start + from).min(lines[n].1), (start + to).min(lines[n].1));
506                            (x_at(from, n), x_at(to, n).max(x_at(from, n) + 8.0))
507                        });
508                        (n as f32 * line_height, theme.color(semantic), under)
509                    })
510                    .collect()
511            })
512            .unwrap_or_default();
513        let gutter_marks: Vec<(f32, ravenrs_core::Color)> = marks.iter().map(|(y, c, _)| (*y, *c)).collect();
514        // Code is as wide as its longest line, and the area scrolls to it; room for the caret after.
515        let widest = advance.map(|advance| {
516            let columns = lines.iter().map(|&(a, b)| b - a).max().unwrap_or(0);
517            columns as f32 * advance + 2.0
518        });
519        let content = cx.container(
520            LayoutStyle {
521                align: Align::Start,
522                grow: if code.is_some() { 1.0 } else { 0.0 },
523                min_width: widest,
524                ..LayoutStyle::stack(Axis::Vertical)
525            },
526            &rows,
527            Paint::Custom(Rc::new(move |f: Rect, scene| {
528                for (y, color, under) in &marks {
529                    let band = Rect::new(
530                        f.x() - AREA_PADDING / 2.0,
531                        f.y() + y,
532                        f.width() + AREA_PADDING,
533                        line_height,
534                    );
535                    scene.push_quad(Quad::new(band, color.with_alpha(0.14)));
536                    if let Some((a, b)) = under {
537                        let line = Rect::new(f.x() + a, f.y() + y + line_height - 3.0, b - a, 2.0);
538                        scene.push_quad(Quad::new(line, *color).radius(1.0));
539                    }
540                }
541                for (y, a, b) in &selected {
542                    let band = Rect::new(f.x() + a, f.y() + y + 2.0, b - a, line_height - 4.0);
543                    scene.push_quad(Quad::new(band, highlight).radius(2.0));
544                }
545                if let Some((x, y)) = caret_spot {
546                    let bar = Rect::new(f.x() + x, f.y() + y + 2.0, 1.5, line_height - 4.0);
547                    scene.push_quad(Quad::new(bar, accent).radius(0.75));
548                }
549            })),
550        );
551        cx.semantics(content, Semantics::hidden());
552
553        // Line numbers, built for the same lines in view, with spacers for the rest.
554        let gutter_fill = state.fill(theme.color(code.as_ref().map_or(Semantic::Control, |c| c.background)));
555        let gutter = gutter_width.map(|width| {
556            let mut numbers = Vec::with_capacity(last - first + 2);
557            if first > 0 {
558                numbers.push(cx.container(
559                    LayoutStyle { height: Some(first as f32 * line_height), ..LayoutStyle::leaf() },
560                    &[],
561                    Paint::None,
562                ));
563            }
564            let marked: Vec<(usize, Semantic)> =
565                code.as_ref().map(|c| c.marks.iter().map(|(n, s, _)| (*n, *s)).collect()).unwrap_or_default();
566            for n in first..last.max(first + 1).min(lines.len().max(1)) {
567                let label = (n + 1).to_string();
568                let semantic = marked.iter().find(|(m, _)| *m == n).map_or(Semantic::InkMuted, |(_, s)| *s);
569                let x = width - cx.measure_text(&label, &attrs).width;
570                let place = LayoutStyle { absolute: Some((x, 0.0)), ..LayoutStyle::leaf() };
571                let number = cx.text(&label, &attrs, state.ink(theme.color(semantic)), place);
572                numbers.push(cx.container(
573                    LayoutStyle { height: Some(line_height), width: Some(width), ..LayoutStyle::leaf() },
574                    &[number],
575                    Paint::None,
576                ));
577            }
578            if last < lines.len() {
579                numbers.push(cx.container(
580                    LayoutStyle {
581                        height: Some((lines.len() - last) as f32 * line_height),
582                        ..LayoutStyle::leaf()
583                    },
584                    &[],
585                    Paint::None,
586                ));
587            }
588            let column = cx.container(
589                LayoutStyle {
590                    width: Some(width + GUTTER_GAP),
591                    // Down to the area's bottom border, even when the code ends higher.
592                    min_height: viewport.map(|v| (v.height - AREA_PADDING / 2.0 - BORDER_ROOM).max(0.0)),
593                    padding: Insets { right: GUTTER_GAP, ..Insets::default() },
594                    ..LayoutStyle::stack(Axis::Vertical)
595                },
596                &numbers,
597                Paint::Custom(Rc::new(move |f: Rect, scene| {
598                    // Over the code that scrolls under it, from inside the area's border to the gap.
599                    let under = Rect::new(
600                        f.x() - AREA_PADDING + BORDER_ROOM,
601                        f.y(),
602                        f.width() + AREA_PADDING - BORDER_ROOM - 4.0,
603                        f.height(),
604                    );
605                    scene.push_quad(Quad::new(under, gutter_fill));
606                    for (y, color) in &gutter_marks {
607                        let bar = Rect::new(f.x() - AREA_PADDING, f.y() + y, 3.0, line_height);
608                        scene.push_quad(Quad::new(bar, *color));
609                    }
610                })),
611            );
612            cx.semantics(column, Semantics::hidden());
613            // Drawn after the code and over it, and moved back by what the code scrolled
614            // sideways, so the numbers stay put.
615            let pinned = cx.container(
616                LayoutStyle { absolute: Some((AREA_PADDING, AREA_PADDING / 2.0)), ..LayoutStyle::leaf() },
617                &[column],
618                Paint::None,
619            );
620            cx.translate(pinned, Point::new(sideways, 0.0));
621            pinned
622        });
623        // What the line numbers take in the row, beside the code.
624        let gutter_space = gutter.map(|_| {
625            cx.container(LayoutStyle { width: Some(gutter_room), ..LayoutStyle::leaf() }, &[], Paint::None)
626        });
627
628        let border = if focused {
629            Border { width: 1.5, color: accent }
630        } else {
631            let base = theme.color(Semantic::Border);
632            Border {
633                width: 1.0,
634                color: state.ink(if hovered { base.lerp(theme.color(Semantic::Ink), 0.25) } else { base }),
635            }
636        };
637        let children: Vec<NodeId> = gutter_space.into_iter().chain([content]).chain(gutter).collect();
638        let background = code.as_ref().map_or(Semantic::Control, |c| c.background);
639        let node = cx.container(
640            LayoutStyle {
641                padding: Insets::symmetric(AREA_PADDING / 2.0, AREA_PADDING),
642                height: if fill { None } else { Some(height) },
643                min_height: fill.then_some(line_height + AREA_PADDING),
644                grow: if fill { 1.0 } else { 0.0 },
645                width: self.width,
646                min_width: Some(160.0),
647                overflow_hidden: true,
648                align_self: Some(if self.width.is_some() { Align::Start } else { Align::Stretch }),
649                ..LayoutStyle::stack(if code.is_some() { Axis::Horizontal } else { Axis::Vertical })
650            },
651            &children,
652            Paint::Quad(QuadPaint {
653                corner_radius: theme.radii.md,
654                border: Some(border),
655                ..QuadPaint::new(state.fill(theme.color(background)))
656            }),
657        );
658        cx.scrollable_virtual(node, code.is_some());
659        crate::motion::control(cx, node);
660        let mut semantics = Semantics::new(Role::TextInput).value(text.clone());
661        if !self.placeholder.is_empty() {
662            semantics = semantics.name(self.placeholder.clone());
663            semantics.fallback_name = true;
664        }
665        cx.semantics(node, state.semantics(semantics));
666        if state.disabled {
667            return node;
668        }
669
670        // Press places the caret, dragging selects, Shift-click extends.
671        let hit = {
672            let (lines, boundaries) = (lines.clone(), boundaries.clone());
673            move |p: Point, frame: Rect| -> usize {
674                let n = (((p.y - frame.y()) / line_height).max(0.0) as usize).min(lines.len() - 1);
675                let Some((start, xs)) = n.checked_sub(first).and_then(|i| boundaries.get(i)) else {
676                    return lines[n].0;
677                };
678                start + index_at(xs, p.x - frame.x())
679            }
680        };
681        let (drag_cursor, mods) = (cursor.clone(), cx.modifiers());
682        cx.on_drag(
683            content,
684            Rc::new(move |d| {
685                let at = hit(d.position, d.frame);
686                match d.phase {
687                    DragPhase::Start => drag_cursor.update(|c| {
688                        if mods.get().shift {
689                            c.move_to(at, true);
690                        } else {
691                            *c = Cursor { caret: at, anchor: Some(at) };
692                        }
693                    }),
694                    DragPhase::Move => drag_cursor.update(|c| c.move_to(at, true)),
695                    _ => {}
696                }
697            }),
698        );
699        let (word_cursor, word_value) = (cursor.clone(), self.value.clone());
700        cx.on_double_click(
701            content,
702            Rc::new(move || {
703                let caret = word_cursor.get().caret;
704                let (a, b) = word_at(&word_value.get(), caret);
705                word_cursor.set(Cursor { caret: b, anchor: Some(a) });
706            }),
707        );
708        cx.cursor(content, CursorIcon::Text);
709
710        let (value, clipboard) = (self.value.clone(), cx.clipboard());
711        let indent = code.as_ref().map_or(0, |c| c.indent);
712        cx.on_key(
713            node,
714            Rc::new(move |key, modifiers| {
715                let mut text = value.get();
716                let mut c = cursor.get();
717                let access = ClipboardAccess { clipboard: &clipboard, secure: false, multiline: true };
718                let handled = edit_area(&mut text, &mut c, &lines, key, modifiers, access, indent);
719                if handled {
720                    let changed = text != value.get();
721                    if changed && !state.accepts_input() {
722                        return true;
723                    }
724                    cursor.set(c);
725                    if changed {
726                        value.set(text);
727                    }
728                }
729                handled
730            }),
731        );
732        cx.focusable(node, theme.radii.md);
733        cx.cursor(node, CursorIcon::Text);
734        node
735    }
736}
737
738// ------------------------------------------------------------- NumberField
739
740/// Reads a number typed with these separators. Besides the exact format it
741/// takes the other common way to write a decimal, so "12.5" works where the
742/// decimal separator is a comma: a lone group separator counts as decimal unless
743/// exactly three digits follow it.
744pub(crate) fn parse_number(text: &str, decimal: char, group: char) -> Option<f64> {
745    let trimmed = text.trim();
746    let normal: String = if trimmed.contains(decimal) {
747        trimmed.chars().filter(|c| *c != group).map(|c| if c == decimal { '.' } else { c }).collect()
748    } else if trimmed.matches(group).count() == 1 {
749        let (_, after) = trimmed.split_once(group)?;
750        if after.chars().count() == 3 {
751            trimmed.chars().filter(|c| *c != group).collect()
752        } else {
753            trimmed.replace(group, ".")
754        }
755    } else {
756        trimmed.chars().filter(|c| *c != group).collect()
757    };
758    let valid = !normal.is_empty()
759        && normal.chars().enumerate().all(|(i, c)| c.is_ascii_digit() || c == '.' || (i == 0 && c == '-'));
760    if !valid {
761        return None;
762    }
763    normal.parse().ok().filter(|n: &f64| n.is_finite())
764}
765
766/// A typed number. While it has focus it shows what is being typed, and without
767/// focus the formatted value, such as `R$ 1.234,50`. Text that is not a number in
768/// range stays visible for fixing and leaves the value alone. Up and Down step.
769///
770/// The separators come from [`crate::Labels`], through [`crate::Localized`].
771///
772/// ```
773/// use ravenrs_reactive::State;
774/// use ravenrs_widgets::{NumberField, Ui};
775///
776/// let price = State::new(Some(1234.5));
777/// let field = NumberField::new(price.binding()).decimals(2).range(0.0, 1e9).prefix("R$ ");
778/// let mut ui = Ui::new(field, ravenrs_style::Theme::raven_dark());
779/// ui.update();
780/// ```
781#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
782pub struct NumberField {
783    value: Binding<Option<f64>>,
784    min: f64,
785    max: f64,
786    step: f64,
787    decimals: usize,
788    grouping: bool,
789    prefix: String,
790    suffix: String,
791    placeholder: String,
792    width: Option<f32>,
793    spin_buttons: bool,
794    label: Option<String>,
795    on_leave: Option<Rc<dyn Fn()>>,
796    state: util::EditorState,
797}
798
799impl std::fmt::Debug for NumberField {
800    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
801        f.debug_struct("NumberField").field("label", &self.label).finish_non_exhaustive()
802    }
803}
804
805#[cfg_attr(feature = "live", ravenrs_live::expose)]
806impl NumberField {
807    /// Called when the keyboard goes elsewhere after being here.
808    pub fn on_leave(mut self, handler: impl Fn() + 'static) -> Self {
809        self.on_leave = Some(Rc::new(handler));
810        self
811    }
812
813    /// A field bound to `value`, unbounded, with no decimals; `None` is empty.
814    pub fn new(value: Binding<Option<f64>>) -> Self {
815        Self {
816            value,
817            min: f64::NEG_INFINITY,
818            max: f64::INFINITY,
819            step: 1.0,
820            decimals: 0,
821            grouping: true,
822            prefix: String::new(),
823            suffix: String::new(),
824            placeholder: String::new(),
825            width: None,
826            spin_buttons: false,
827            label: None,
828            on_leave: None,
829            state: util::EditorState::default(),
830        }
831    }
832
833    /// A field for money held as a whole number of cents: exact, two decimals,
834    /// showing `prefix`, such as `"R$ "`.
835    pub fn money(cents: Binding<Option<i64>>, prefix: impl Into<String>) -> Self {
836        let value = cents.map(
837            |c| c.map(|c| c as f64 / 100.0),
838            |c, v: Option<f64>| *c = v.map(|v| (v * 100.0).round() as i64),
839        );
840        Self::new(value).decimals(2).step(1.0).prefix(prefix)
841    }
842
843    /// Accepts only `min..=max`.
844    pub fn range(mut self, min: f64, max: f64) -> Self {
845        self.min = min;
846        self.max = max.max(min);
847        self
848    }
849
850    /// How much Up, Down and the buttons change the value. 1 by default.
851    pub fn step(mut self, step: f64) -> Self {
852        self.step = step;
853        self
854    }
855
856    /// Digits shown after the decimal separator without focus. 0 by default.
857    pub fn decimals(mut self, decimals: usize) -> Self {
858        self.decimals = decimals;
859        self
860    }
861
862    /// Whether thousands are separated without focus. On by default.
863    pub fn grouping(mut self, grouping: bool) -> Self {
864        self.grouping = grouping;
865        self
866    }
867
868    /// Text before the number without focus, such as a currency.
869    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
870        self.prefix = prefix.into();
871        self
872    }
873
874    /// Text after the number without focus, such as `%`.
875    pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
876        self.suffix = suffix.into();
877        self
878    }
879
880    /// Muted text shown while the field is empty.
881    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
882        self.placeholder = text.into();
883        self
884    }
885
886    /// Fixed width in logical pixels; by default the field stretches.
887    pub fn width(mut self, width: f32) -> Self {
888        self.width = Some(width);
889        self
890    }
891
892    /// The name screen readers announce. Inside a [`crate::FormField`], its label is used instead.
893    pub fn label(mut self, label: impl Into<String>) -> Self {
894        self.label = Some(label.into());
895        self
896    }
897
898    /// Shows buttons that take a step away and add one.
899    pub fn spin_buttons(mut self) -> Self {
900        self.spin_buttons = true;
901        self
902    }
903
904    /// Turns the field off: it fades, leaves the tab order and ignores input.
905    pub fn disabled(mut self, disabled: bool) -> Self {
906        self.state.disabled = disabled;
907        self
908    }
909
910    /// Shows the value without letting it change; it can still be focused and read.
911    pub fn read_only(mut self, read_only: bool) -> Self {
912        self.state.read_only = read_only;
913        self
914    }
915}
916
917impl View for NumberField {
918    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
919        let labels = cx.labels();
920        let (decimal, group) = (labels.decimal_separator, labels.group_separator);
921        // What was typed, with the value it was typed over; a value that changed
922        // from outside since then wins over the old text.
923        let draft: State<Option<(String, Option<f64>)>> = cx.use_state(|| None);
924        let value = self.value.get();
925        if draft.get().is_some_and(|(_, over)| over != value) {
926            draft.set(None);
927        }
928        let plain = move |n: f64| {
929            ravenrs_criteria::NumberStyle {
930                decimals: fraction_digits(n),
931                group: None,
932                decimal,
933                prefix: "",
934                suffix: "",
935            }
936            .format(n)
937        };
938        let (min, max) = (self.min, self.max);
939        let typed = draft.get();
940        let formatted = value.map_or_else(String::new, |n| {
941            ravenrs_criteria::NumberStyle {
942                decimals: self.decimals,
943                group: self.grouping.then_some(group),
944                decimal,
945                prefix: &self.prefix,
946                suffix: &self.suffix,
947            }
948            .format(n)
949        });
950        let invalid = typed.as_ref().is_some_and(|(text, _)| {
951            !text.trim().is_empty()
952                && parse_number(text, decimal, group).is_none_or(|n| !(min..=max).contains(&n))
953        });
954
955        let (read, write, target) = (draft.clone(), draft.clone(), self.value.clone());
956        let current = self.value.clone();
957        let text = Binding::new(
958            move || read.get().map(|(text, _)| text).or_else(|| current.get().map(plain)).unwrap_or_default(),
959            move |text: String| {
960                let parsed = parse_number(&text, decimal, group).filter(|n| (min..=max).contains(n));
961                let next = if text.trim().is_empty() { None } else { parsed.or(target.get()) };
962                write.set(Some((text, next)));
963                if next != target.get() {
964                    target.set(next);
965                }
966            },
967        );
968        let (step, stepped, cleared) = (self.step, self.value.clone(), draft.clone());
969        let bump = Rc::new(move |up: bool| {
970            let base = stepped.get().unwrap_or(if min.is_finite() { min } else { 0.0 });
971            let next = if up { base + step } else { base - step };
972            cleared.set(None);
973            stepped.set(Some(next.clamp(min, max)));
974        });
975        let keys = bump.clone();
976        let accepts = self.state.accepts_input();
977        let mut field = TextField::new(text)
978            .placeholder(self.placeholder.clone())
979            .disabled(self.state.disabled)
980            .read_only(self.state.read_only)
981            .intercept_keys(Rc::new(move |key, _| match key {
982                Key::ArrowUp | Key::ArrowDown if accepts => {
983                    keys(*key == Key::ArrowUp);
984                    true
985                }
986                _ => false,
987            }));
988        if let Some(left) = self.on_leave.clone() {
989            field = field.on_leave(move || left());
990        }
991        // Bad text stays on show so it can be fixed; otherwise the formatted value.
992        if !invalid {
993            field.idle_text = Some(formatted);
994        }
995        if let Some(width) = self.width {
996            field = field.width(width);
997        }
998        let node = cx.child(&field);
999        if let Some(label) = &self.label {
1000            cx.update_semantics(node, |s| s.name = Some(label.clone()));
1001        }
1002        if !self.spin_buttons {
1003            return node;
1004        }
1005        let (down, up) = (bump.clone(), bump);
1006        let minus = cx.child(
1007            &IconButton::new(IconName::Minus)
1008                .label(labels.step_down.clone())
1009                .size(ButtonSize::Small)
1010                .disabled(!accepts)
1011                .on_click(move || down(false)),
1012        );
1013        let plus = cx.child(
1014            &IconButton::new(IconName::Plus)
1015                .label(labels.step_up.clone())
1016                .size(ButtonSize::Small)
1017                .disabled(!accepts)
1018                .on_click(move || up(true)),
1019        );
1020        cx.container(
1021            LayoutStyle {
1022                spacing: 4.0,
1023                align: Align::Center,
1024                align_self: Some(if self.width.is_some() { Align::Start } else { Align::Stretch }),
1025                ..LayoutStyle::stack(Axis::Horizontal)
1026            },
1027            &[node, minus, plus],
1028            Paint::None,
1029        )
1030    }
1031}
1032
1033/// The fewest decimals that show `n` as it is, when editing it.
1034fn fraction_digits(n: f64) -> usize {
1035    let text = n.to_string();
1036    text.split_once('.').map_or(0, |(_, fraction)| fraction.len())
1037}
1038
1039// ------------------------------------------------------------- MaskedField
1040
1041#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1042enum MaskSlot {
1043    Digit,
1044    Letter,
1045    Any,
1046    Literal(char),
1047}
1048
1049#[cfg_attr(feature = "live", ravenrs_live::expose)]
1050impl MaskSlot {
1051    fn accepts(self, c: char) -> bool {
1052        match self {
1053            Self::Digit => c.is_ascii_digit(),
1054            Self::Letter => c.is_alphabetic(),
1055            Self::Any => c.is_alphanumeric(),
1056            Self::Literal(_) => false,
1057        }
1058    }
1059}
1060
1061/// `0` is a digit, `a` a letter and `*` a letter or digit. `\` makes the next
1062/// character plain, and any other character is shown as it is.
1063fn parse_mask(mask: &str) -> Vec<MaskSlot> {
1064    let mut chars = mask.chars();
1065    let mut slots = Vec::new();
1066    while let Some(c) = chars.next() {
1067        slots.push(match c {
1068            '0' => MaskSlot::Digit,
1069            'a' => MaskSlot::Letter,
1070            '*' => MaskSlot::Any,
1071            '\\' => MaskSlot::Literal(chars.next().unwrap_or('\\')),
1072            other => MaskSlot::Literal(other),
1073        });
1074    }
1075    slots
1076}
1077
1078/// Pulls what the slots accept out of text in the mask's format, or any text.
1079fn mask_extract(slots: &[MaskSlot], text: &str) -> String {
1080    let mut chars = text.chars().peekable();
1081    let mut raw = String::new();
1082    for slot in slots {
1083        match slot {
1084            MaskSlot::Literal(l) => {
1085                if chars.peek() == Some(l) {
1086                    chars.next();
1087                }
1088            }
1089            slot => {
1090                for c in chars.by_ref() {
1091                    if slot.accepts(c) {
1092                        raw.push(c);
1093                        break;
1094                    }
1095                }
1096            }
1097        }
1098    }
1099    raw
1100}
1101
1102/// Puts `raw` in the mask, stopping where it runs out.
1103fn mask_fill(slots: &[MaskSlot], raw: &str) -> String {
1104    let mut chars = raw.chars();
1105    let mut out = String::new();
1106    let mut pending = String::new();
1107    for slot in slots {
1108        match slot {
1109            MaskSlot::Literal(l) => pending.push(*l),
1110            _ => {
1111                let Some(c) = chars.next() else { break };
1112                out.push_str(&pending);
1113                pending.clear();
1114                out.push(c);
1115            }
1116        }
1117    }
1118    out
1119}
1120
1121/// A text input that follows a pattern, such as `(00) 00000-0000`, in which `0`
1122/// is a digit, `a` a letter and `*` either; `\` makes the next character plain.
1123/// The binding holds only what was typed, without the pattern's own characters,
1124/// unless [`Self::keep_literals`] asks for them.
1125///
1126/// ```
1127/// use ravenrs_reactive::State;
1128/// use ravenrs_widgets::{MaskedField, Ui};
1129///
1130/// let phone = State::new(String::from("11987654321"));
1131/// let field = MaskedField::new(phone.binding(), "(00) 00000-0000");
1132/// let mut ui = Ui::new(field, ravenrs_style::Theme::raven_dark());
1133/// ui.update();
1134/// ```
1135#[derive(Debug)]
1136#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1137pub struct MaskedField {
1138    value: Binding<String>,
1139    mask: String,
1140    keep_literals: bool,
1141    placeholder: Option<String>,
1142    width: Option<f32>,
1143    label: Option<String>,
1144    state: util::EditorState,
1145}
1146
1147#[cfg_attr(feature = "live", ravenrs_live::expose)]
1148impl MaskedField {
1149    /// A field bound to `value`, following `mask`.
1150    pub fn new(value: Binding<String>, mask: impl Into<String>) -> Self {
1151        Self {
1152            value,
1153            mask: mask.into(),
1154            keep_literals: false,
1155            placeholder: None,
1156            width: None,
1157            label: None,
1158            state: util::EditorState::default(),
1159        }
1160    }
1161
1162    /// The name screen readers announce. Inside a [`crate::FormField`], its label is used instead.
1163    pub fn label(mut self, label: impl Into<String>) -> Self {
1164        self.label = Some(label.into());
1165        self
1166    }
1167
1168    /// The binding holds the text as shown, with the pattern's own characters.
1169    pub fn keep_literals(mut self) -> Self {
1170        self.keep_literals = true;
1171        self
1172    }
1173
1174    /// Muted text shown while empty; by default the pattern with `_` in its slots.
1175    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
1176        self.placeholder = Some(text.into());
1177        self
1178    }
1179
1180    /// Fixed width in logical pixels; by default the field stretches.
1181    pub fn width(mut self, width: f32) -> Self {
1182        self.width = Some(width);
1183        self
1184    }
1185
1186    /// Turns the field off: it fades, leaves the tab order and ignores input.
1187    pub fn disabled(mut self, disabled: bool) -> Self {
1188        self.state.disabled = disabled;
1189        self
1190    }
1191
1192    /// Shows the value without letting it change; it can still be focused and read.
1193    pub fn read_only(mut self, read_only: bool) -> Self {
1194        self.state.read_only = read_only;
1195        self
1196    }
1197}
1198
1199impl View for MaskedField {
1200    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1201        let slots = Rc::new(parse_mask(&self.mask));
1202        let hint = self.placeholder.clone().unwrap_or_else(|| {
1203            slots.iter().map(|s| if let MaskSlot::Literal(l) = s { *l } else { '_' }).collect::<String>()
1204        });
1205        let (read, write) = (self.value.clone(), self.value.clone());
1206        let (from_raw, to_raw) = (slots.clone(), slots);
1207        let keep = self.keep_literals;
1208        let shown = Binding::new(
1209            move || {
1210                let value = read.get();
1211                let raw = if keep { mask_extract(&from_raw, &value) } else { value };
1212                mask_fill(&from_raw, &raw)
1213            },
1214            move |text: String| {
1215                let raw = mask_extract(&to_raw, &text);
1216                let next = if keep { mask_fill(&to_raw, &raw) } else { raw };
1217                if next != write.get() {
1218                    write.set(next);
1219                }
1220            },
1221        );
1222        let mut field = TextField::new(shown)
1223            .placeholder(hint)
1224            .disabled(self.state.disabled)
1225            .read_only(self.state.read_only);
1226        field.caret_follows_end = true;
1227        if let Some(width) = self.width {
1228            field = field.width(width);
1229        }
1230        let node = cx.child(&field);
1231        if let Some(label) = &self.label {
1232            cx.update_semantics(node, |s| s.name = Some(label.clone()));
1233        }
1234        node
1235    }
1236}
1237
1238// ------------------------------------------------------------ Time editors
1239
1240/// A time of day, with no date or time zone.
1241///
1242/// ```
1243/// use ravenrs_widgets::Time;
1244///
1245/// let t = Time::new(9, 30).unwrap();
1246/// assert_eq!(t.to_string(), "09:30");
1247/// assert!(Time::new(24, 0).is_none());
1248/// ```
1249#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
1250#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1251pub struct Time {
1252    hour: u8,
1253    minute: u8,
1254}
1255
1256#[cfg_attr(feature = "live", ravenrs_live::expose)]
1257impl Time {
1258    /// Returns `None` for an impossible time.
1259    pub fn new(hour: u8, minute: u8) -> Option<Self> {
1260        (hour < 24 && minute < 60).then_some(Self { hour, minute })
1261    }
1262
1263    /// The hour, from 0 to 23.
1264    pub fn hour(self) -> u8 {
1265        self.hour
1266    }
1267
1268    /// The minute, from 0 to 59.
1269    pub fn minute(self) -> u8 {
1270        self.minute
1271    }
1272}
1273
1274impl fmt::Display for Time {
1275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1276        write!(f, "{:02}:{:02}", self.hour, self.minute)
1277    }
1278}
1279
1280/// A date and a time of day, with no time zone.
1281#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
1282#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1283pub struct DateTime {
1284    /// The day.
1285    pub date: Date,
1286    /// The time on that day.
1287    pub time: Time,
1288}
1289
1290/// A 24-hour time field: type it, or choose from a list in steps of
1291/// [`Self::step_minutes`].
1292///
1293/// ```
1294/// use ravenrs_reactive::State;
1295/// use ravenrs_widgets::{Time, TimePicker, Ui};
1296///
1297/// let at = State::new(Time::new(9, 30));
1298/// let mut ui = Ui::new(TimePicker::new(at.binding()), ravenrs_style::Theme::raven_dark());
1299/// ui.update();
1300/// ```
1301#[derive(Debug)]
1302#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1303pub struct TimePicker {
1304    value: Binding<Option<Time>>,
1305    step: u16,
1306    width: f32,
1307    label: Option<String>,
1308    state: util::EditorState,
1309}
1310
1311#[cfg_attr(feature = "live", ravenrs_live::expose)]
1312impl TimePicker {
1313    /// A picker bound to `value`; `None` is empty.
1314    pub fn new(value: Binding<Option<Time>>) -> Self {
1315        Self { value, step: 30, width: 120.0, label: None, state: util::EditorState::default() }
1316    }
1317
1318    /// The minutes between the times in the list. 30 by default.
1319    pub fn step_minutes(mut self, minutes: u16) -> Self {
1320        self.step = minutes.clamp(1, 720);
1321        self
1322    }
1323
1324    /// The name screen readers announce. Inside a [`crate::FormField`], its label is used instead.
1325    pub fn label(mut self, label: impl Into<String>) -> Self {
1326        self.label = Some(label.into());
1327        self
1328    }
1329
1330    /// Field width in logical pixels. 120 by default.
1331    pub fn width(mut self, width: f32) -> Self {
1332        self.width = width;
1333        self
1334    }
1335
1336    /// Turns the picker off: it fades, leaves the tab order and ignores input.
1337    pub fn disabled(mut self, disabled: bool) -> Self {
1338        self.state.disabled = disabled;
1339        self
1340    }
1341
1342    /// Shows the time without letting it change; it can still be focused and read.
1343    pub fn read_only(mut self, read_only: bool) -> Self {
1344        self.state.read_only = read_only;
1345        self
1346    }
1347}
1348
1349/// Reads the four digits a time field holds, once they are all there.
1350fn time_from_digits(digits: &str) -> Option<Time> {
1351    if digits.len() != 4 {
1352        return None;
1353    }
1354    Time::new(digits[..2].parse().ok()?, digits[2..].parse().ok()?)
1355}
1356
1357impl View for TimePicker {
1358    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1359        let open: State<bool> = cx.use_state(|| false);
1360        // Unfinished digits live here until they make a time.
1361        let draft: State<Option<String>> = cx.use_state(|| None);
1362        let labels = cx.labels();
1363        let (read, write, target, value) =
1364            (draft.clone(), draft.clone(), self.value.clone(), self.value.clone());
1365        let digits = Binding::new(
1366            move || {
1367                read.get().unwrap_or_else(|| {
1368                    value.get().map_or_else(String::new, |t| format!("{:02}{:02}", t.hour, t.minute))
1369                })
1370            },
1371            move |text: String| {
1372                let time = time_from_digits(&text);
1373                write.set(if time.is_some() || text.is_empty() { None } else { Some(text.clone()) });
1374                if time.is_some() || text.is_empty() {
1375                    target.set(time);
1376                }
1377            },
1378        );
1379        let mut field = MaskedField::new(digits, "00:00")
1380            .width(self.width)
1381            .disabled(self.state.disabled)
1382            .read_only(self.state.read_only);
1383        if let Some(label) = &self.label {
1384            field = field.label(label.clone());
1385        }
1386        let field = cx.child(&field);
1387        let accepts = self.state.accepts_input();
1388        let toggle = open.clone();
1389        let button = cx.child(
1390            &IconButton::new(IconName::Clock)
1391                .label(labels.pick_time.clone())
1392                .disabled(!accepts)
1393                .on_click(move || toggle.update(|o| *o = !*o)),
1394        );
1395        let node = cx.container(
1396            LayoutStyle {
1397                spacing: 4.0,
1398                align: Align::Center,
1399                align_self: Some(Align::Fit),
1400                ..LayoutStyle::stack(Axis::Horizontal)
1401            },
1402            &[field, button],
1403            Paint::None,
1404        );
1405        if open.get() && accepts {
1406            let slots: Vec<Time> = (0..24 * 60)
1407                .step_by(self.step as usize)
1408                .filter_map(|m: u16| Time::new((m / 60) as u8, (m % 60) as u8))
1409                .collect();
1410            let options: Vec<String> = slots.iter().map(Time::to_string).collect();
1411            let chosen = self.value.clone();
1412            let near = self.value.get().map_or(0, |t| slots.iter().rposition(|s| *s <= t).unwrap_or(0));
1413            let (clear, closing) = (draft, open.clone());
1414            let selected = Binding::new(
1415                move || near,
1416                move |i: usize| {
1417                    clear.set(None);
1418                    chosen.set(slots.get(i).copied());
1419                },
1420            );
1421            let close: Rc<dyn Fn()> = Rc::new(move || closing.set(false));
1422            let list = Options { options: &options, selected, close: close.clone(), width: self.width };
1423            cx.overlay(node, Placement::Below, &list, Some(close));
1424        }
1425        node
1426    }
1427}
1428
1429/// A [`DatePicker`] beside a [`TimePicker`], over one value. Picking a date first
1430/// sets the time to midnight, and clearing the time clears both.
1431#[derive(Debug)]
1432#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1433pub struct DateTimePicker {
1434    value: Binding<Option<DateTime>>,
1435    month: Date,
1436    step: u16,
1437    label: Option<String>,
1438    state: util::EditorState,
1439}
1440
1441#[cfg_attr(feature = "live", ravenrs_live::expose)]
1442impl DateTimePicker {
1443    /// `month` is shown when no date is selected yet.
1444    pub fn new(value: Binding<Option<DateTime>>, month: Date) -> Self {
1445        Self { value, month, step: 30, label: None, state: util::EditorState::default() }
1446    }
1447
1448    /// The name screen readers announce. Both of its fields use it.
1449    pub fn label(mut self, label: impl Into<String>) -> Self {
1450        self.label = Some(label.into());
1451        self
1452    }
1453
1454    /// The minutes between the times in the list. 30 by default.
1455    pub fn step_minutes(mut self, minutes: u16) -> Self {
1456        self.step = minutes.clamp(1, 720);
1457        self
1458    }
1459
1460    /// Turns the picker off: it fades, leaves the tab order and ignores input.
1461    pub fn disabled(mut self, disabled: bool) -> Self {
1462        self.state.disabled = disabled;
1463        self
1464    }
1465
1466    /// Shows the value without letting it change; it can still be focused and read.
1467    pub fn read_only(mut self, read_only: bool) -> Self {
1468        self.state.read_only = read_only;
1469        self
1470    }
1471}
1472
1473impl View for DateTimePicker {
1474    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1475        let date = self.value.map(
1476            |v| v.map(|v| v.date),
1477            |v, date: Option<Date>| {
1478                *v = date.map(|date| DateTime { date, time: v.map_or_else(Time::default, |old| old.time) });
1479            },
1480        );
1481        let time = self.value.map(
1482            |v| v.map(|v| v.time),
1483            |v, time: Option<Time>| {
1484                *v = match (*v, time) {
1485                    (Some(old), Some(time)) => Some(DateTime { date: old.date, time }),
1486                    _ => None,
1487                };
1488            },
1489        );
1490        let mut day = DatePicker::new(date, self.month)
1491            .fill()
1492            .disabled(self.state.disabled)
1493            .read_only(self.state.read_only);
1494        let mut hour = TimePicker::new(time)
1495            .step_minutes(self.step)
1496            .disabled(self.state.disabled)
1497            .read_only(self.state.read_only);
1498        if let Some(label) = &self.label {
1499            day = day.label(label.clone());
1500            hour = hour.label(label.clone());
1501        }
1502        let (day, hour) =
1503            (cx.child(&VStack::new((day,)).spacing(0.0).align(Align::Stretch).grow(1.0)), cx.child(&hour));
1504        cx.container(
1505            LayoutStyle {
1506                spacing: 8.0,
1507                align: Align::Center,
1508                align_self: Some(Align::Stretch),
1509                wrap: true,
1510                ..LayoutStyle::stack(Axis::Horizontal)
1511            },
1512            &[day, hour],
1513            Paint::None,
1514        )
1515    }
1516}
1517
1518#[cfg(test)]
1519mod tests {
1520    use super::*;
1521
1522    fn by_char(s: &str) -> f32 {
1523        s.chars().count() as f32 * 10.0
1524    }
1525
1526    fn pieces(text: &str, width: f32) -> Vec<String> {
1527        let chars: Vec<char> = text.chars().collect();
1528        wrap_lines(text, width, by_char).iter().map(|&(a, b)| chars[a..b].iter().collect()).collect()
1529    }
1530
1531    #[test]
1532    fn lines_break_at_line_breaks_and_after_spaces() {
1533        assert_eq!(pieces("", 100.0), [""]);
1534        assert_eq!(pieces("one\ntwo", 100.0), ["one", "two"]);
1535        assert_eq!(pieces("one\n", 100.0), ["one", ""]);
1536        assert_eq!(pieces("\n\n", 100.0), ["", "", ""]);
1537        assert_eq!(pieces("aaaa bbbb cccc", 100.0), ["aaaa bbbb ", "cccc"]);
1538    }
1539
1540    #[test]
1541    fn a_word_wider_than_the_line_is_cut() {
1542        assert_eq!(pieces("abcdefghijkl", 50.0), ["abcde", "fghij", "kl"]);
1543        assert_eq!(pieces("hi abcdefghijkl yo", 50.0), ["hi ", "abcde", "fghij", "kl yo"]);
1544    }
1545
1546    #[test]
1547    fn lines_cover_every_char_but_the_breaks() {
1548        let text = "the quick brown fox\njumps over\n\nthe lazy dog";
1549        let lines = wrap_lines(text, 70.0, by_char);
1550        let count: usize = lines.iter().map(|(a, b)| b - a).sum();
1551        assert_eq!(count + text.matches('\n').count(), text.chars().count());
1552        assert!(lines.windows(2).all(|w| w[0].1 <= w[1].0));
1553    }
1554
1555    #[test]
1556    fn numbers_in_both_ways_of_writing_them() {
1557        let pt = |s| parse_number(s, ',', '.');
1558        assert_eq!(pt("1.234,50"), Some(1234.5));
1559        assert_eq!(pt("12,5"), Some(12.5));
1560        assert_eq!(pt("12.5"), Some(12.5), "a dot that cannot be thousands is the decimal");
1561        assert_eq!(pt("1.234"), Some(1234.0), "three digits after it are thousands");
1562        assert_eq!(pt("-3"), Some(-3.0));
1563        assert_eq!(pt(" 7 "), Some(7.0));
1564        for bad in ["", "abc", "1-2", "1,2,3", "--1", "1e5"] {
1565            assert_eq!(pt(bad), None, "{bad}");
1566        }
1567        let en = |s| parse_number(s, '.', ',');
1568        assert_eq!(en("1,234.50"), Some(1234.5));
1569        assert_eq!(en("12,5"), Some(12.5));
1570    }
1571
1572    #[test]
1573    fn masks_fill_and_extract() {
1574        let phone = parse_mask("(00) 00000-0000");
1575        assert_eq!(mask_fill(&phone, "11987654321"), "(11) 98765-4321");
1576        assert_eq!(mask_fill(&phone, "119"), "(11) 9");
1577        assert_eq!(mask_fill(&phone, ""), "");
1578        assert_eq!(mask_extract(&phone, "(11) 98765-4321"), "11987654321");
1579        assert_eq!(mask_extract(&phone, "11x9"), "119", "what a slot refuses is skipped");
1580        assert_eq!(mask_extract(&phone, "(11) 98765-43219999"), "11987654321", "no more than it holds");
1581        let plate = parse_mask("aaa-0*00");
1582        assert_eq!(mask_extract(&plate, "ABC1D23"), "ABC1D23");
1583        assert_eq!(mask_fill(&plate, "ABC1D23"), "ABC-1D23");
1584        assert_eq!(parse_mask(r"\0-0"), [MaskSlot::Literal('0'), MaskSlot::Literal('-'), MaskSlot::Digit]);
1585    }
1586
1587    #[test]
1588    fn four_digits_make_a_time() {
1589        assert_eq!(time_from_digits("0930"), Time::new(9, 30));
1590        assert_eq!(time_from_digits("093"), None);
1591        assert_eq!(time_from_digits("2460"), None);
1592    }
1593}