1use 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
17const SLIDER_WIDTH: f32 = 200.0;
19const FIELD_WIDTH: f32 = 84.0;
21
22#[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 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 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 pub fn step(mut self, step: f32) -> Self {
90 self.step = step.max(f32::EPSILON);
91 self
92 }
93
94 pub fn unit(mut self, unit: impl Into<String>) -> Self {
96 self.unit = unit.into();
97 self
98 }
99
100 pub fn label(mut self, label: impl Into<String>) -> Self {
102 self.label = Some(label.into());
103 self
104 }
105
106 pub fn show_label(mut self) -> Self {
108 self.show_label = true;
109 self
110 }
111
112 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 pub fn neutral(mut self, neutral: f32) -> Self {
121 self.neutral = Some(neutral);
122 self
123 }
124
125 pub fn slider_width(mut self, width: f32) -> Self {
127 self.slider_width = width;
128 self
129 }
130
131 pub fn field(mut self) -> Self {
135 self.field = true;
136 self
137 }
138
139 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 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
208struct 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 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
249struct 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 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 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
325pub(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#[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 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 pub fn editing(mut self, editing: Binding<bool>) -> Self {
381 self.editing = Some(editing);
382 self
383 }
384
385 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 pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
394 self.on_click = Some(Rc::new(handler));
395 self
396 }
397
398 pub fn size(mut self, size: ButtonSize) -> Self {
400 self.size = size;
401 self
402 }
403
404 pub fn width(mut self, width: f32) -> Self {
406 self.width = Some(width);
407 self
408 }
409
410 pub fn label(mut self, label: impl Into<String>) -> Self {
412 self.label = Some(label.into());
413 self
414 }
415
416 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
418 self.placeholder = text.into();
419 self
420 }
421
422 pub fn disabled(mut self, disabled: bool) -> Self {
424 self.disabled = disabled;
425 self
426 }
427}
428
429struct 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 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}