1use 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
18const DEFAULT_ROWS: usize = 4;
22const FIRST_GUESS_WIDTH: f32 = 320.0;
24const AREA_PADDING: f32 = 12.0;
25const BORDER_ROOM: f32 = 1.5;
27
28pub(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 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 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
100fn line_of(lines: &[(usize, usize)], caret: usize) -> usize {
103 lines.iter().rposition(|(start, _)| *start <= caret).unwrap_or(0)
104}
105
106pub(crate) type Highlighter = Rc<dyn Fn(&str) -> Vec<(usize, usize, Semantic)>>;
108
109pub(crate) type LineMark = (usize, Semantic, Option<(usize, usize)>);
111
112#[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 pub(crate) indent: usize,
121 pub(crate) fill: bool,
123 pub(crate) background: Semantic,
124}
125
126type DrawnMark = (f32, ravenrs_core::Color, Option<(f32, f32)>);
128
129const GUTTER_GAP: f32 = 16.0;
131
132#[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 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 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 #[cfg(feature = "code")]
181 pub(crate) fn code(mut self, look: CodeLook) -> Self {
182 self.code = Some(look);
183 self
184 }
185
186 pub(crate) fn cursor(mut self, cursor: State<Cursor>) -> Self {
189 self.cursor = Some(cursor);
190 self
191 }
192
193 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
195 self.placeholder = text.into();
196 self
197 }
198
199 pub fn rows(mut self, rows: usize) -> Self {
201 self.rows = rows.max(1);
202 self
203 }
204
205 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 pub fn width(mut self, width: f32) -> Self {
215 self.width = Some(width);
216 self
217 }
218
219 pub fn disabled(mut self, disabled: bool) -> Self {
221 self.state.disabled = disabled;
222 self
223 }
224
225 pub fn read_only(mut self, read_only: bool) -> Self {
227 self.state.read_only = read_only;
228 self
229 }
230}
231
232fn 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
245fn 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 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 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 let advance = code.is_some().then(|| cx.measure_text("0000000000", &attrs).width / 10.0);
345 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 let scrolled = cx.scroll_offset();
357 let mut offset = scrolled.y;
358 let mut sideways = if code.is_some() { scrolled.x } else { 0.0 };
360 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 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 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 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 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 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 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 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 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 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 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 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 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 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
738pub(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#[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 pub fn on_leave(mut self, handler: impl Fn() + 'static) -> Self {
809 self.on_leave = Some(Rc::new(handler));
810 self
811 }
812
813 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 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 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 pub fn step(mut self, step: f64) -> Self {
852 self.step = step;
853 self
854 }
855
856 pub fn decimals(mut self, decimals: usize) -> Self {
858 self.decimals = decimals;
859 self
860 }
861
862 pub fn grouping(mut self, grouping: bool) -> Self {
864 self.grouping = grouping;
865 self
866 }
867
868 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
870 self.prefix = prefix.into();
871 self
872 }
873
874 pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
876 self.suffix = suffix.into();
877 self
878 }
879
880 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
882 self.placeholder = text.into();
883 self
884 }
885
886 pub fn width(mut self, width: f32) -> Self {
888 self.width = Some(width);
889 self
890 }
891
892 pub fn label(mut self, label: impl Into<String>) -> Self {
894 self.label = Some(label.into());
895 self
896 }
897
898 pub fn spin_buttons(mut self) -> Self {
900 self.spin_buttons = true;
901 self
902 }
903
904 pub fn disabled(mut self, disabled: bool) -> Self {
906 self.state.disabled = disabled;
907 self
908 }
909
910 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 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 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
1033fn fraction_digits(n: f64) -> usize {
1035 let text = n.to_string();
1036 text.split_once('.').map_or(0, |(_, fraction)| fraction.len())
1037}
1038
1039#[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
1061fn 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
1078fn 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
1102fn 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#[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 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 pub fn label(mut self, label: impl Into<String>) -> Self {
1164 self.label = Some(label.into());
1165 self
1166 }
1167
1168 pub fn keep_literals(mut self) -> Self {
1170 self.keep_literals = true;
1171 self
1172 }
1173
1174 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
1176 self.placeholder = Some(text.into());
1177 self
1178 }
1179
1180 pub fn width(mut self, width: f32) -> Self {
1182 self.width = Some(width);
1183 self
1184 }
1185
1186 pub fn disabled(mut self, disabled: bool) -> Self {
1188 self.state.disabled = disabled;
1189 self
1190 }
1191
1192 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#[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 pub fn new(hour: u8, minute: u8) -> Option<Self> {
1260 (hour < 24 && minute < 60).then_some(Self { hour, minute })
1261 }
1262
1263 pub fn hour(self) -> u8 {
1265 self.hour
1266 }
1267
1268 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#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
1282#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
1283pub struct DateTime {
1284 pub date: Date,
1286 pub time: Time,
1288}
1289
1290#[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 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 pub fn step_minutes(mut self, minutes: u16) -> Self {
1320 self.step = minutes.clamp(1, 720);
1321 self
1322 }
1323
1324 pub fn label(mut self, label: impl Into<String>) -> Self {
1326 self.label = Some(label.into());
1327 self
1328 }
1329
1330 pub fn width(mut self, width: f32) -> Self {
1332 self.width = width;
1333 self
1334 }
1335
1336 pub fn disabled(mut self, disabled: bool) -> Self {
1338 self.state.disabled = disabled;
1339 self
1340 }
1341
1342 pub fn read_only(mut self, read_only: bool) -> Self {
1344 self.state.read_only = read_only;
1345 self
1346 }
1347}
1348
1349fn 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 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#[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 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 pub fn label(mut self, label: impl Into<String>) -> Self {
1450 self.label = Some(label.into());
1451 self
1452 }
1453
1454 pub fn step_minutes(mut self, minutes: u16) -> Self {
1456 self.step = minutes.clamp(1, 720);
1457 self
1458 }
1459
1460 pub fn disabled(mut self, disabled: bool) -> Self {
1462 self.state.disabled = disabled;
1463 self
1464 }
1465
1466 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}