Skip to main content

ravenrs_widgets/
quick_edit.rs

1//! Controls that stay small until they are used: a value that opens a slider, and a name
2//! that turns into a field.
3
4use std::cell::Cell;
5use std::fmt;
6use std::rc::Rc;
7
8use ravenrs_core::{CursorIcon, Insets, Key, Role};
9use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
10use ravenrs_reactive::{Binding, State};
11use ravenrs_style::{Semantic, TextStyle};
12
13use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
14use crate::editors::parse_number;
15use crate::{Button, ButtonSize, ButtonStyle, IconButton, IconName, Popover, Slider, TextField, View, util};
16
17/// The width of the slider a [`ValueSlider`] opens, in logical pixels.
18const SLIDER_WIDTH: f32 = 200.0;
19/// The width of the number field of [`ValueSlider::field`], in logical pixels.
20const FIELD_WIDTH: f32 = 84.0;
21
22/// A value shown small, as "100% ▾", that opens a [`Slider`] in a popover, for bars with no
23/// room for a slider of their own.
24///
25/// Enter or Space on the button opens it with the keyboard in the slider: the arrows, Home
26/// and End change the value, and Escape closes it and goes back to the button. With
27/// [`ValueSlider::field`] the number can also be typed.
28///
29/// ```
30/// use ravenrs_reactive::State;
31/// use ravenrs_widgets::{Ui, ValueSlider};
32///
33/// let opacity = State::new(100.0_f32);
34/// let control = ValueSlider::new(opacity.binding()).range(0.0, 100.0).step(1.0).unit("%").label("Opacity");
35/// let mut ui = Ui::new(control, ravenrs_style::Theme::raven_dark());
36/// ui.update();
37/// ```
38#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
39pub struct ValueSlider {
40    value: Binding<f32>,
41    min: f32,
42    max: f32,
43    step: f32,
44    unit: String,
45    label: Option<String>,
46    show_label: bool,
47    format: Option<Rc<dyn Fn(f32) -> String>>,
48    neutral: Option<f32>,
49    slider_width: f32,
50    field: bool,
51    disabled: bool,
52}
53
54impl fmt::Debug for ValueSlider {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.debug_struct("ValueSlider").field("min", &self.min).field("max", &self.max).finish_non_exhaustive()
57    }
58}
59
60#[cfg_attr(feature = "live", ravenrs_live::expose)]
61impl ValueSlider {
62    /// A control over 0.0–1.0 bound to `value`, stepping by 0.01.
63    pub fn new(value: Binding<f32>) -> Self {
64        Self {
65            value,
66            min: 0.0,
67            max: 1.0,
68            step: 0.01,
69            unit: String::new(),
70            label: None,
71            show_label: false,
72            format: None,
73            neutral: None,
74            slider_width: SLIDER_WIDTH,
75            field: false,
76            disabled: false,
77        }
78    }
79
80    /// The value range; also resets the step to 1/100 of the range.
81    pub fn range(mut self, min: f32, max: f32) -> Self {
82        self.min = min;
83        self.max = max.max(min + f32::EPSILON);
84        self.step = (self.max - self.min) / 100.0;
85        self
86    }
87
88    /// The keyboard increment (call after [`ValueSlider::range`]).
89    pub fn step(mut self, step: f32) -> Self {
90        self.step = step.max(f32::EPSILON);
91        self
92    }
93
94    /// Text right after the number, such as `%` or ` px`.
95    pub fn unit(mut self, unit: impl Into<String>) -> Self {
96        self.unit = unit.into();
97        self
98    }
99
100    /// What the value is, such as "Opacity", for screen readers. See [`Self::show_label`].
101    pub fn label(mut self, label: impl Into<String>) -> Self {
102        self.label = Some(label.into());
103        self
104    }
105
106    /// Also writes the [`label`](Self::label) before the value.
107    pub fn show_label(mut self) -> Self {
108        self.show_label = true;
109        self
110    }
111
112    /// Writes the value as this function does, instead of the number and the unit.
113    /// [`Self::field`] goes on showing the number, since that is what can be typed.
114    pub fn value_format(mut self, format: impl Fn(f32) -> String + 'static) -> Self {
115        self.format = Some(Rc::new(format));
116        self
117    }
118
119    /// The value at rest: see [`Slider::neutral`].
120    pub fn neutral(mut self, neutral: f32) -> Self {
121        self.neutral = Some(neutral);
122        self
123    }
124
125    /// The width of the slider in the popover, in logical pixels (200 by default).
126    pub fn slider_width(mut self, width: f32) -> Self {
127        self.slider_width = width;
128        self
129    }
130
131    /// A number field with the unit and a small arrow that opens the slider, instead of a
132    /// button. Enter or leaving the field takes what was typed, up to the limits; text that
133    /// is not a number, or Escape, brings the value back. Up and Down take a step.
134    pub fn field(mut self) -> Self {
135        self.field = true;
136        self
137    }
138
139    /// Turns it off: it fades, leaves the tab order and does not open.
140    pub fn disabled(mut self, disabled: bool) -> Self {
141        self.disabled = disabled;
142        self
143    }
144
145    fn number(&self, value: f32, decimal: char) -> String {
146        ravenrs_criteria::NumberStyle {
147            decimals: crate::inputs::step_decimals(self.step),
148            group: None,
149            decimal,
150            prefix: "",
151            suffix: "",
152        }
153        .format(f64::from(value))
154    }
155
156    fn number_field(&self, cx: &mut BuildCx<'_>, open: &State<bool>, name: &str, value: f32) -> TextField {
157        let labels = cx.labels();
158        let (decimal, group) = (labels.decimal_separator, labels.group_separator);
159        // What was typed; nothing while the field shows the value itself.
160        let draft: State<Option<String>> = cx.use_state(|| None);
161        let shown = self.number(value, decimal);
162        let (read, write) = (draft.clone(), draft.clone());
163        let text = Binding::new(
164            move || read.get().unwrap_or_else(|| shown.clone()),
165            move |typed: String| write.set(Some(typed)),
166        );
167        let (min, max, step) = (self.min, self.max, self.step);
168        let snap = move |v: f32| (((v - min) / step).round() * step + min).clamp(min, max);
169        let commit: Rc<dyn Fn()> = {
170            let (draft, target) = (draft.clone(), self.value.clone());
171            Rc::new(move || {
172                let typed = draft.get();
173                draft.set_if_changed(None);
174                let number = typed.and_then(|text| parse_number(&text, decimal, group));
175                if let Some(number) = number {
176                    let next = snap(number as f32);
177                    if next != target.get() {
178                        target.set(next);
179                    }
180                }
181            })
182        };
183        let (on_enter, on_leave) = (commit.clone(), commit);
184        let (cancelled, stepped, cleared) = (draft.clone(), self.value.clone(), draft);
185        let suffix = FieldSuffix { unit: self.unit.clone(), open: open.clone(), name: name.to_owned() };
186        TextField::new(text)
187            .size(ButtonSize::Small)
188            .width(FIELD_WIDTH)
189            .end_aligned()
190            .placeholder(name)
191            .suffix(suffix)
192            .on_submit(move |_| on_enter())
193            .on_leave(move || on_leave())
194            .on_cancel(move || cancelled.set_if_changed(None))
195            .intercept_keys(Rc::new(move |key, _| {
196                let delta = match key {
197                    Key::ArrowUp => step,
198                    Key::ArrowDown => -step,
199                    _ => return false,
200                };
201                cleared.set_if_changed(None);
202                stepped.update(|v| *v = snap(*v + delta));
203                true
204            }))
205    }
206}
207
208/// The unit and the arrow of a [`ValueSlider::field`].
209struct FieldSuffix {
210    unit: String,
211    open: State<bool>,
212    name: String,
213}
214
215impl View for FieldSuffix {
216    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
217        let theme = *cx.theme();
218        let mut children = Vec::with_capacity(2);
219        let unit = self.unit.trim();
220        if !unit.is_empty() {
221            // As wide as it is: in a tight field, a text that gives way breaks into lines.
222            let attrs = util::attrs(&theme, TextStyle::Callout, None);
223            let width = cx.measure_text(unit, &attrs).width.ceil();
224            let style = LayoutStyle { width: Some(width), ..LayoutStyle::leaf() };
225            children.push(cx.text(unit, &attrs, theme.color(Semantic::InkMuted), style));
226        }
227        let toggle = self.open.clone();
228        let arrow = cx.child(
229            &IconButton::new(IconName::ChevronDown)
230                .label(self.name.clone())
231                .size(ButtonSize::Mini)
232                .on_click(move || toggle.update(|open| *open = !*open)),
233        );
234        let is_open = self.open.get();
235        cx.update_semantics(arrow, |s| s.expanded = Some(is_open));
236        children.push(arrow);
237        cx.container(
238            LayoutStyle {
239                spacing: theme.spacing.s1,
240                align: Align::Center,
241                ..LayoutStyle::stack(Axis::Horizontal)
242            },
243            &children,
244            Paint::None,
245        )
246    }
247}
248
249/// The content of the popover: the slider, which gets the keyboard when it opens.
250struct SliderPanel(Slider);
251
252impl View for SliderPanel {
253    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
254        let slider = cx.child(&self.0);
255        let focused = cx.use_state(|| Rc::new(Cell::new(false))).get();
256        if !focused.replace(true) {
257            cx.request_focus_node(slider);
258        }
259        slider
260    }
261}
262
263impl View for ValueSlider {
264    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
265        let theme = *cx.theme();
266        let labels = cx.labels();
267        let open: State<bool> = cx.use_state(|| false);
268        let value = self.value.get().clamp(self.min, self.max);
269        let name = self.label.clone().unwrap_or_else(|| labels.value_adjust.clone());
270
271        let mut slider = Slider::new(self.value.clone())
272            .range(self.min, self.max)
273            .step(self.step)
274            .width(self.slider_width)
275            .label(name.clone());
276        if let Some(neutral) = self.neutral {
277            slider = slider.neutral(neutral);
278        }
279        let panel = SliderPanel(slider);
280        let shows = if self.disabled { Binding::constant(false) } else { open.binding() };
281        let anchor = if self.field {
282            let field = self.number_field(cx, &open, &name, value).disabled(self.disabled);
283            cx.child(&Popover::new(shows, field, panel).padding(theme.spacing.s3).min_width(0.0))
284        } else {
285            let text = match &self.format {
286                Some(format) => format(value),
287                None => format!("{}{}", self.number(value, labels.decimal_separator), self.unit),
288            };
289            let toggle = open.clone();
290            let button = Button::new(text.clone())
291                .style(ButtonStyle::Secondary)
292                .size(ButtonSize::Small)
293                .trailing_icon(IconName::ChevronDown)
294                .accessible_name(format!("{name}: {text}"))
295                .disabled(self.disabled)
296                .on_click(move || toggle.update(|open| *open = !*open));
297            let node = cx.child(&Popover::new(shows, button, panel).padding(theme.spacing.s3).min_width(0.0));
298            let is_open = open.get();
299            cx.update_semantics(node, |s| s.expanded = Some(is_open));
300            node
301        };
302        let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
303        if open.get() {
304            was_open.set(true);
305        } else if was_open.replace(false) {
306            // The keyboard was in the popover, which is gone.
307            cx.request_focus_node(anchor);
308        }
309        let Some(label) = self.label.as_ref().filter(|_| self.show_label) else { return anchor };
310        let text = util::label(cx, label, TextStyle::Callout, None, theme.color(Semantic::InkMuted));
311        // The control says it already.
312        cx.semantics(text, Semantics::hidden());
313        cx.container(
314            LayoutStyle {
315                spacing: theme.spacing.s2,
316                align: Align::Center,
317                ..LayoutStyle::stack(Axis::Horizontal)
318            },
319            &[text, anchor],
320            Paint::None,
321        )
322    }
323}
324
325/// Whether this is the F2 key, however the platform spells it.
326pub(crate) fn is_f2(key: &Key) -> bool {
327    matches!(key, Key::Other(name) if name.trim_start_matches("Named(").trim_end_matches(')') == "F2")
328}
329
330/// A name that turns into a field: a double click, F2 or Enter starts, Enter confirms,
331/// Escape cancels, and clicking elsewhere confirms. The keyboard goes back to the name after
332/// Enter or Escape. An empty name, or one that did not change, changes nothing.
333///
334/// ```
335/// use ravenrs_reactive::State;
336/// use ravenrs_widgets::{InlineRename, Ui};
337///
338/// let name = State::new(String::from("Warm evening"));
339/// let rename = InlineRename::new(name.binding()).label("Preset name").on_rename(|new| println!("{new}"));
340/// let mut ui = Ui::new(rename, ravenrs_style::Theme::raven_dark());
341/// ui.update();
342/// ```
343#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
344pub struct InlineRename {
345    value: Binding<String>,
346    editing: Option<Binding<bool>>,
347    on_rename: Option<Rc<dyn Fn(String)>>,
348    on_click: Option<Rc<dyn Fn()>>,
349    size: ButtonSize,
350    width: Option<f32>,
351    label: Option<String>,
352    placeholder: String,
353    disabled: bool,
354}
355
356impl fmt::Debug for InlineRename {
357    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
358        f.debug_struct("InlineRename").field("label", &self.label).finish_non_exhaustive()
359    }
360}
361
362#[cfg_attr(feature = "live", ravenrs_live::expose)]
363impl InlineRename {
364    /// A name bound to `value`, which changes only when the new name is confirmed.
365    pub fn new(value: Binding<String>) -> Self {
366        Self {
367            value,
368            editing: None,
369            on_rename: None,
370            on_click: None,
371            size: ButtonSize::Small,
372            width: None,
373            label: None,
374            placeholder: String::new(),
375            disabled: false,
376        }
377    }
378
379    /// Whether it is being edited, for starting from elsewhere, such as a menu.
380    pub fn editing(mut self, editing: Binding<bool>) -> Self {
381        self.editing = Some(editing);
382        self
383    }
384
385    /// Called with the new name when it is confirmed and differs from the old one.
386    pub fn on_rename(mut self, handler: impl Fn(String) + 'static) -> Self {
387        self.on_rename = Some(Rc::new(handler));
388        self
389    }
390
391    /// What a single click on the name does, such as selecting its row, since the name
392    /// takes the clicks that land on it.
393    pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
394        self.on_click = Some(Rc::new(handler));
395        self
396    }
397
398    /// The height and text size, in both forms so that nothing moves (`Small` by default).
399    pub fn size(mut self, size: ButtonSize) -> Self {
400        self.size = size;
401        self
402    }
403
404    /// Fixed width in logical pixels. It takes the width of its content by default.
405    pub fn width(mut self, width: f32) -> Self {
406        self.width = Some(width);
407        self
408    }
409
410    /// What is being named, such as "Preset name", for screen readers.
411    pub fn label(mut self, label: impl Into<String>) -> Self {
412        self.label = Some(label.into());
413        self
414    }
415
416    /// Muted text shown while the name is empty.
417    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
418        self.placeholder = text.into();
419        self
420    }
421
422    /// Shows the name and does not let it change.
423    pub fn disabled(mut self, disabled: bool) -> Self {
424        self.disabled = disabled;
425        self
426    }
427}
428
429/// The field of an [`InlineRename`]. Its own view, so that each edit starts from the name
430/// of now and not from what the last one left.
431struct RenameField<'a> {
432    rename: &'a InlineRename,
433    editing: Binding<bool>,
434    refocus: State<bool>,
435}
436
437impl View for RenameField<'_> {
438    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
439        let rename = self.rename;
440        let draft: State<String> = cx.use_state(|| rename.value.get());
441        let finish: Rc<dyn Fn(bool)> = {
442            let (draft, value, handler) = (draft.clone(), rename.value.clone(), rename.on_rename.clone());
443            let (editing, refocus) = (self.editing.clone(), self.refocus.clone());
444            Rc::new(move |keyboard_stays| {
445                let name = draft.get().trim().to_owned();
446                if !name.is_empty() && name != value.get() {
447                    value.set(name.clone());
448                    if let Some(handler) = &handler {
449                        handler(name);
450                    }
451                }
452                editing.set(false);
453                refocus.set(keyboard_stays);
454            })
455        };
456        let (on_enter, on_leave) = (finish.clone(), finish);
457        let (editing, refocus) = (self.editing.clone(), self.refocus.clone());
458        let labels = cx.labels();
459        let mut field = TextField::new(draft.binding())
460            .size(rename.size)
461            .placeholder(rename.label.clone().unwrap_or_else(|| labels.rename.clone()))
462            .autofocus()
463            .select_on_open()
464            .on_submit(move |_| on_enter(true))
465            .on_leave(move || on_leave(false))
466            .on_cancel(move || {
467                editing.set(false);
468                refocus.set(true);
469            });
470        if let Some(width) = rename.width {
471            field = field.width(width);
472        }
473        field.build(cx)
474    }
475}
476
477impl View for InlineRename {
478    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
479        let theme = *cx.theme();
480        let labels = cx.labels();
481        let own: State<bool> = cx.use_state(|| false);
482        let refocus: State<bool> = cx.use_state(|| false);
483        let editing = self.editing.clone().unwrap_or_else(|| own.binding());
484        if editing.get() && !self.disabled {
485            let field = RenameField { rename: self, editing, refocus };
486            return cx.keyed_child(1, &field);
487        }
488        if *refocus.peek() {
489            refocus.set(false);
490            cx.request_focus();
491        }
492
493        let name = self.value.get();
494        let empty = name.is_empty();
495        let attrs = util::attrs(&theme, self.size.text_style(), None);
496        let (shown, ink) = if empty {
497            (self.placeholder.as_str(), Semantic::InkMuted)
498        } else {
499            (name.as_str(), Semantic::Ink)
500        };
501        let text = cx.text_truncated(
502            shown,
503            &attrs,
504            theme.color(ink),
505            LayoutStyle { min_width: Some(0.0), ..LayoutStyle::leaf() },
506        );
507        let hovered = cx.is_hovered() && !self.disabled;
508        let fill = if hovered {
509            theme.color(Semantic::Ink).with_alpha(0.05)
510        } else {
511            ravenrs_core::Color::TRANSPARENT
512        };
513        let node = cx.container(
514            LayoutStyle {
515                width: self.width,
516                min_width: Some(0.0),
517                // The same room as the field, so that the name does not move when it opens.
518                min_height: Some(self.size.height()),
519                padding: Insets::symmetric(0.0, theme.spacing.s2),
520                align: Align::Center,
521                justify: Justify::Start,
522                ..LayoutStyle::stack(Axis::Horizontal)
523            },
524            &[text],
525            Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, ..QuadPaint::new(fill) }),
526        );
527        cx.semantics(text, Semantics::hidden());
528        let announced = if empty { self.label.clone().unwrap_or_default() } else { name };
529        if self.disabled {
530            cx.semantics(node, Semantics::new(Role::Label).name(announced).disabled(true));
531            return node;
532        }
533        cx.semantics(
534            node,
535            Semantics::new(Role::Button).name(announced).description(labels.rename_hint.clone()),
536        );
537        let start = editing.clone();
538        cx.on_double_click(node, Rc::new(move || start.set(true)));
539        cx.on_key(
540            node,
541            Rc::new(move |key, _| {
542                if matches!(key, Key::Enter | Key::Space) || is_f2(key) {
543                    editing.set(true);
544                    return true;
545                }
546                false
547            }),
548        );
549        if let Some(click) = &self.on_click {
550            cx.on_click(node, click.clone());
551        }
552        cx.focusable(node, theme.radii.sm);
553        cx.cursor(node, CursorIcon::Default);
554        node
555    }
556}