1use gpui_base::StyledExt as _;
4use std::sync::Arc;
5
6use gpui::Hsla;
7use gpui::{
8 AccessibleAction, AppContext as _, Axis, Context, Entity, EventEmitter, FocusHandle,
9 Focusable as _, InteractiveElement as _, IntoElement, MouseButton, Orientation,
10 ParentElement as _, Render, Role, ScrollHandle, SharedString, StatefulInteractiveElement as _,
11 Styled as _, Subscription, Window, div, prelude::FluentBuilder as _, relative,
12};
13use gpui_base::{
14 Decrement, Increment, SliderIndicator, SliderThumb, SliderTrack, StepAction, step_value,
15};
16use gpui_component::{
17 ActiveTheme as _, Colorize as _, Disableable as _, Sizable as _,
18 button::Button,
19 color_picker::{ColorPicker, ColorPickerEvent, ColorPickerState},
20 h_flex,
21 input::{Input, InputEvent, InputState},
22 label::Label,
23 menu::{DropdownMenu as _, PopupMenuItem},
24 scroll::ScrollableMask,
25 slider::{SliderEvent, SliderState},
26 v_flex,
27};
28
29use crate::{
30 control::{outlined_control, outlined_control_with_label},
31 theme::SemanticStyledExt as _,
32};
33
34const MIN_DIMENSION: f64 = 1.;
35const MAX_DIMENSION: f64 = 4_096.;
36const MIN_RADIUS: f64 = 0.;
37const MAX_RADIUS: f64 = 2_048.;
38
39fn finite_clamp(value: f64, min: f64, max: f64) -> f64 {
40 if value.is_finite() {
41 value.clamp(min, max)
42 } else {
43 min
44 }
45}
46
47fn normalize_opacity(opacity: f32) -> f32 {
48 if opacity.is_finite() {
49 opacity.clamp(0., 1.)
50 } else {
51 0.
52 }
53}
54
55#[derive(Clone, Debug, PartialEq)]
57pub struct FineTuneValues {
58 width: f64,
59 height: f64,
60 radius: f64,
61 opacity: f32,
62 typeface_id: SharedString,
63 accent: Option<Hsla>,
64}
65
66impl FineTuneValues {
67 pub fn new(
72 width: f64,
73 height: f64,
74 radius: f64,
75 opacity: f32,
76 typeface_id: impl Into<SharedString>,
77 ) -> Self {
78 Self {
79 width: finite_clamp(width, MIN_DIMENSION, MAX_DIMENSION),
80 height: finite_clamp(height, MIN_DIMENSION, MAX_DIMENSION),
81 radius: finite_clamp(radius, MIN_RADIUS, MAX_RADIUS),
82 opacity: normalize_opacity(opacity),
83 typeface_id: typeface_id.into(),
84 accent: None,
85 }
86 }
87
88 pub fn accent(mut self, accent: Hsla) -> Self {
90 self.accent = Some(accent);
91 self
92 }
93
94 pub fn with_width(mut self, width: f64) -> Self {
96 self.width = finite_clamp(width, MIN_DIMENSION, MAX_DIMENSION);
97 self
98 }
99
100 pub fn with_height(mut self, height: f64) -> Self {
102 self.height = finite_clamp(height, MIN_DIMENSION, MAX_DIMENSION);
103 self
104 }
105
106 pub fn with_radius(mut self, radius: f64) -> Self {
108 self.radius = finite_clamp(radius, MIN_RADIUS, MAX_RADIUS);
109 self
110 }
111
112 pub fn with_opacity(mut self, opacity: f32) -> Self {
114 self.opacity = normalize_opacity(opacity);
115 self
116 }
117
118 pub fn with_typeface(mut self, typeface_id: impl Into<SharedString>) -> Self {
120 self.typeface_id = typeface_id.into();
121 self
122 }
123
124 pub fn with_accent(mut self, accent: Option<Hsla>) -> Self {
126 self.accent = accent;
127 self
128 }
129
130 pub fn width(&self) -> f64 {
132 self.width
133 }
134
135 pub fn height(&self) -> f64 {
137 self.height
138 }
139
140 pub fn radius(&self) -> f64 {
142 self.radius
143 }
144
145 pub fn opacity(&self) -> f32 {
147 self.opacity
148 }
149
150 pub fn typeface_id(&self) -> &SharedString {
152 &self.typeface_id
153 }
154
155 pub fn accent_color(&self) -> Option<Hsla> {
157 self.accent
158 }
159}
160
161#[derive(Clone, Debug, PartialEq, Eq)]
165pub struct FineTuneTypeface {
166 id: SharedString,
167 label: SharedString,
168}
169
170impl FineTuneTypeface {
171 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
173 Self {
174 id: id.into(),
175 label: label.into(),
176 }
177 }
178
179 pub fn id(&self) -> &SharedString {
181 &self.id
182 }
183
184 pub fn label(&self) -> &SharedString {
186 &self.label
187 }
188}
189
190#[derive(Clone, Debug, PartialEq)]
192pub enum FineTuneEvent {
193 WidthChanged {
195 id: SharedString,
197 width: f64,
199 },
200 HeightChanged {
202 id: SharedString,
204 height: f64,
206 },
207 RadiusChanged {
209 id: SharedString,
211 radius: f64,
213 },
214 OpacityChanged {
216 id: SharedString,
218 opacity: f32,
220 },
221 TypefaceChanged {
223 id: SharedString,
225 typeface_id: SharedString,
227 },
228 AccentChanged {
230 id: SharedString,
232 accent: Option<Hsla>,
234 },
235 ResetRequested {
237 id: SharedString,
239 },
240 ApplyRequested {
242 id: SharedString,
244 },
245}
246
247impl FineTuneEvent {
248 pub fn id(&self) -> &SharedString {
250 match self {
251 Self::WidthChanged { id, .. }
252 | Self::HeightChanged { id, .. }
253 | Self::RadiusChanged { id, .. }
254 | Self::OpacityChanged { id, .. }
255 | Self::TypefaceChanged { id, .. }
256 | Self::AccentChanged { id, .. }
257 | Self::ResetRequested { id }
258 | Self::ApplyRequested { id } => id,
259 }
260 }
261}
262
263#[derive(Clone, Copy)]
264enum NumericProperty {
265 Width,
266 Height,
267 Radius,
268 Opacity,
269}
270
271pub struct FineTuneCard {
273 style: gpui::StyleRefinement,
280 id: SharedString,
281 values: FineTuneValues,
282 typefaces: Arc<[FineTuneTypeface]>,
283 scroll_handle: ScrollHandle,
285 width_input: Entity<InputState>,
286 height_input: Entity<InputState>,
287 radius_input: Entity<InputState>,
288 opacity_input: Entity<InputState>,
289 opacity_slider: Entity<SliderState>,
290 opacity_slider_focus: FocusHandle,
291 accent_picker: Entity<ColorPickerState>,
292 local_width: f64,
293 local_height: f64,
294 local_radius: f64,
295 local_opacity: f32,
296 local_typeface_id: SharedString,
297 local_accent: Option<Hsla>,
298 _subscriptions: Vec<Subscription>,
299}
300
301impl FineTuneCard {
302 pub fn new<I>(
304 id: impl Into<SharedString>,
305 values: FineTuneValues,
306 typefaces: I,
307 window: &mut Window,
308 cx: &mut Context<Self>,
309 ) -> Self
310 where
311 I: IntoIterator<Item = FineTuneTypeface>,
312 {
313 let typefaces = valid_typefaces(typefaces).unwrap_or_default();
314 let width_input = cx.new(|cx| {
315 InputState::new(window, cx)
316 .default_value(values.width.to_string())
317 .step(1.)
318 .min(MIN_DIMENSION)
319 .max(MAX_DIMENSION)
320 });
321 let height_input = cx.new(|cx| {
322 InputState::new(window, cx)
323 .default_value(values.height.to_string())
324 .step(1.)
325 .min(MIN_DIMENSION)
326 .max(MAX_DIMENSION)
327 });
328 let radius_input = cx.new(|cx| {
329 InputState::new(window, cx)
330 .default_value(values.radius.to_string())
331 .step(1.)
332 .min(MIN_RADIUS)
333 .max(MAX_RADIUS)
334 });
335 let opacity_input = cx.new(|cx| {
336 InputState::new(window, cx)
337 .default_value((values.opacity * 100.).to_string())
338 .step(1.)
339 .min(0.)
340 .max(100.)
341 });
342 let opacity_slider = cx.new(|_| {
343 SliderState::new()
344 .min(0.)
345 .max(100.)
346 .step(1.)
347 .default_value(values.opacity * 100.)
348 });
349 let opacity_slider_focus = cx.focus_handle();
350 let accent_picker = cx.new(|cx| {
351 let state = ColorPickerState::new(window, cx);
352 if let Some(accent) = values.accent {
353 state.default_value(accent)
354 } else {
355 state
356 }
357 });
358 let width_subscription =
359 cx.subscribe_in(&width_input, window, |this, input, event, window, cx| {
360 this.on_numeric_input_event(NumericProperty::Width, input, event, window, cx)
361 });
362 let height_subscription =
363 cx.subscribe_in(&height_input, window, |this, input, event, window, cx| {
364 this.on_numeric_input_event(NumericProperty::Height, input, event, window, cx)
365 });
366 let radius_subscription =
367 cx.subscribe_in(&radius_input, window, |this, input, event, window, cx| {
368 this.on_numeric_input_event(NumericProperty::Radius, input, event, window, cx)
369 });
370 let opacity_subscription =
371 cx.subscribe_in(&opacity_input, window, |this, input, event, window, cx| {
372 this.on_numeric_input_event(NumericProperty::Opacity, input, event, window, cx)
373 });
374 let slider_subscription = cx.subscribe_in(
375 &opacity_slider,
376 window,
377 |this, _, event: &SliderEvent, window, cx| {
378 if let SliderEvent::Change(value) = event {
379 this.change_opacity(value.end() / 100., window, cx);
380 }
381 },
382 );
383 let accent_subscription = cx.subscribe_in(
384 &accent_picker,
385 window,
386 |this, _, event: &ColorPickerEvent, window, cx| match event {
387 ColorPickerEvent::Change(accent) => {
388 this.change_accent(*accent, window, cx);
389 }
390 },
391 );
392 Self {
393 style: gpui::StyleRefinement::default(),
394 id: id.into(),
395 local_width: values.width,
396 local_height: values.height,
397 local_radius: values.radius,
398 local_opacity: values.opacity,
399 local_typeface_id: values.typeface_id.clone(),
400 local_accent: values.accent,
401 values,
402 typefaces,
403 width_input,
404 height_input,
405 radius_input,
406 opacity_input,
407 opacity_slider,
408 opacity_slider_focus,
409 accent_picker,
410 scroll_handle: ScrollHandle::new(),
411 _subscriptions: vec![
412 width_subscription,
413 height_subscription,
414 radius_subscription,
415 opacity_subscription,
416 slider_subscription,
417 accent_subscription,
418 ],
419 }
420 }
421
422 pub fn set_values(
428 &mut self,
429 values: FineTuneValues,
430 window: &mut Window,
431 cx: &mut Context<Self>,
432 ) {
433 let changed = self.values != values
434 || self.local_width != values.width
435 || self.local_height != values.height
436 || self.local_radius != values.radius
437 || self.local_opacity != values.opacity
438 || self.local_typeface_id != values.typeface_id
439 || self.local_accent != values.accent;
440 self.local_width = values.width;
441 self.local_height = values.height;
442 self.local_radius = values.radius;
443 self.local_opacity = values.opacity;
444 self.local_typeface_id = values.typeface_id.clone();
445 self.local_accent = values.accent;
446
447 Self::sync_numeric_input(
448 &self.width_input,
449 NumericProperty::Width,
450 values.width,
451 window,
452 cx,
453 );
454 Self::sync_numeric_input(
455 &self.height_input,
456 NumericProperty::Height,
457 values.height,
458 window,
459 cx,
460 );
461 Self::sync_numeric_input(
462 &self.radius_input,
463 NumericProperty::Radius,
464 values.radius,
465 window,
466 cx,
467 );
468 Self::sync_numeric_input(
469 &self.opacity_input,
470 NumericProperty::Opacity,
471 f64::from(values.opacity * 100.),
472 window,
473 cx,
474 );
475 let slider_value = values.opacity * 100.;
476 if self.opacity_slider.read(cx).value().end() != slider_value {
477 self.opacity_slider
478 .update(cx, |slider, cx| slider.set_value(slider_value, window, cx));
479 }
480 if self.accent_picker.read(cx).value() != values.accent {
481 self.accent_picker.update(cx, |picker, cx| {
482 if let Some(accent) = values.accent {
483 picker.set_value(accent, window, cx);
484 } else {
485 picker.clear_value(window, cx);
486 }
487 });
488 }
489
490 self.values = values;
491 if changed {
492 cx.notify();
493 }
494 }
495
496 pub fn set_typefaces<I>(&mut self, typefaces: I, cx: &mut Context<Self>)
501 where
502 I: IntoIterator<Item = FineTuneTypeface>,
503 {
504 let Some(typefaces) = valid_typefaces(typefaces) else {
505 return;
506 };
507 if self.typefaces != typefaces {
508 self.typefaces = typefaces;
509 cx.notify();
510 }
511 }
512
513 fn sync_numeric_input(
514 input: &Entity<InputState>,
515 property: NumericProperty,
516 target: f64,
517 window: &mut Window,
518 cx: &mut Context<Self>,
519 ) {
520 let text = input.read(cx).value();
521 let current = text.trim().parse::<f64>().ok().filter(|value| {
522 value.is_finite()
523 && match property {
524 NumericProperty::Width | NumericProperty::Height => {
525 (MIN_DIMENSION..=MAX_DIMENSION).contains(value)
526 }
527 NumericProperty::Radius => (MIN_RADIUS..=MAX_RADIUS).contains(value),
528 NumericProperty::Opacity => (0.0..=100.0).contains(value),
529 }
530 });
531 if current == Some(target) {
534 return;
535 }
536 input.update(cx, |input, cx| {
537 input.set_value(target.to_string(), window, cx)
538 });
539 }
540
541 fn on_numeric_input_event(
542 &mut self,
543 property: NumericProperty,
544 input: &Entity<InputState>,
545 event: &InputEvent,
546 window: &mut Window,
547 cx: &mut Context<Self>,
548 ) {
549 match event {
550 InputEvent::Change => {
551 let text = input.read(cx).value();
552 let Ok(value) = text.trim().parse::<f64>() else {
553 return;
554 };
555 self.change_numeric(property, value, window, cx);
556 }
557 InputEvent::Blur if input.read(cx).value().trim().parse::<f64>().is_err() => {
558 let value = match property {
559 NumericProperty::Width => self.values.width,
560 NumericProperty::Height => self.values.height,
561 NumericProperty::Radius => self.values.radius,
562 NumericProperty::Opacity => f64::from(self.values.opacity * 100.),
563 };
564 input.update(cx, |input, cx| {
565 input.set_value(value.to_string(), window, cx)
566 });
567 match property {
568 NumericProperty::Width => self.local_width = self.values.width,
569 NumericProperty::Height => self.local_height = self.values.height,
570 NumericProperty::Radius => self.local_radius = self.values.radius,
571 NumericProperty::Opacity => self.local_opacity = self.values.opacity,
572 }
573 cx.notify();
574 }
575 InputEvent::PressEnter { .. } | InputEvent::Focus | InputEvent::Blur => {}
576 }
577 }
578
579 fn change_numeric(
580 &mut self,
581 property: NumericProperty,
582 value: f64,
583 window: &mut Window,
584 cx: &mut Context<Self>,
585 ) {
586 match property {
587 NumericProperty::Width => {
588 let width = finite_clamp(value, MIN_DIMENSION, MAX_DIMENSION);
589 if self.local_width != width {
590 self.local_width = width;
591 cx.notify();
592 cx.emit(FineTuneEvent::WidthChanged {
593 id: self.id.clone(),
594 width,
595 });
596 }
597 }
598 NumericProperty::Height => {
599 let height = finite_clamp(value, MIN_DIMENSION, MAX_DIMENSION);
600 if self.local_height != height {
601 self.local_height = height;
602 cx.notify();
603 cx.emit(FineTuneEvent::HeightChanged {
604 id: self.id.clone(),
605 height,
606 });
607 }
608 }
609 NumericProperty::Radius => {
610 let radius = finite_clamp(value, MIN_RADIUS, MAX_RADIUS);
611 if self.local_radius != radius {
612 self.local_radius = radius;
613 cx.notify();
614 cx.emit(FineTuneEvent::RadiusChanged {
615 id: self.id.clone(),
616 radius,
617 });
618 }
619 }
620 NumericProperty::Opacity => {
621 self.change_opacity((value / 100.) as f32, window, cx);
622 }
623 }
624 }
625
626 fn change_opacity(&mut self, opacity: f32, window: &mut Window, cx: &mut Context<Self>) {
627 let opacity = normalize_opacity(opacity);
628 if self.local_opacity == opacity {
629 return;
630 }
631 self.local_opacity = opacity;
632 let percentage = opacity * 100.;
633 let input_percentage = self
634 .opacity_input
635 .read(cx)
636 .value()
637 .trim()
638 .parse::<f32>()
639 .ok();
640 if input_percentage != Some(percentage) {
641 self.opacity_input.update(cx, |input, cx| {
642 input.set_value(percentage.to_string(), window, cx)
643 });
644 }
645 if self.opacity_slider.read(cx).value().end() != percentage {
646 self.opacity_slider
647 .update(cx, |slider, cx| slider.set_value(percentage, window, cx));
648 }
649 cx.notify();
650 cx.emit(FineTuneEvent::OpacityChanged {
651 id: self.id.clone(),
652 opacity,
653 });
654 }
655
656 fn step_opacity(&mut self, step: f32, window: &mut Window, cx: &mut Context<Self>) {
657 let percentage = (self.local_opacity * 100. + step).clamp(0., 100.);
658 self.change_opacity(percentage / 100., window, cx);
659 }
660
661 fn select_typeface(&mut self, typeface_id: impl Into<SharedString>, cx: &mut Context<Self>) {
662 let typeface_id = typeface_id.into();
663 if self.local_typeface_id == typeface_id
664 || !self.typefaces.iter().any(|item| item.id == typeface_id)
665 {
666 return;
667 }
668 self.local_typeface_id = typeface_id.clone();
669 cx.notify();
670 cx.emit(FineTuneEvent::TypefaceChanged {
671 id: self.id.clone(),
672 typeface_id,
673 });
674 }
675
676 fn change_accent(&mut self, accent: Option<Hsla>, window: &mut Window, cx: &mut Context<Self>) {
677 if self.local_accent == accent {
678 return;
679 }
680 self.local_accent = accent;
681 if self.accent_picker.read(cx).value() != accent {
682 self.accent_picker.update(cx, |picker, cx| {
683 if let Some(accent) = accent {
684 picker.set_value(accent, window, cx);
685 } else {
686 picker.clear_value(window, cx);
687 }
688 });
689 }
690 cx.notify();
691 cx.emit(FineTuneEvent::AccentChanged {
692 id: self.id.clone(),
693 accent,
694 });
695 }
696}
697
698fn valid_typefaces<I>(typefaces: I) -> Option<Arc<[FineTuneTypeface]>>
699where
700 I: IntoIterator<Item = FineTuneTypeface>,
701{
702 let mut validated = Vec::new();
703 for typeface in typefaces {
704 if typeface.id.is_empty()
705 || validated
706 .iter()
707 .any(|item: &FineTuneTypeface| item.id == typeface.id)
708 {
709 return None;
710 }
711 validated.push(typeface);
712 }
713 Some(validated.into())
714}
715
716impl EventEmitter<FineTuneEvent> for FineTuneCard {}
717
718#[derive(Clone, Copy)]
719struct NumericSemantics {
720 value: f64,
721 min: f64,
722 max: f64,
723}
724
725fn step_numeric_editor(
726 input: &Entity<InputState>,
727 action: StepAction,
728 semantics: NumericSemantics,
729 window: &mut Window,
730 cx: &mut gpui::App,
731) {
732 let text = input.read(cx).value();
733 let Some(next) = step_value(&text, action, 1., Some(semantics.min), Some(semantics.max)) else {
734 return;
735 };
736 input.update(cx, |input, cx| input.replace_all(next, window, cx));
737}
738
739fn named_number_input(
740 card_id: &SharedString,
741 key: &'static str,
742 label: &'static str,
743 semantics: NumericSemantics,
744 input: &Entity<InputState>,
745 suffix: &'static str,
746 cx: &mut gpui::App,
747) -> gpui::Stateful<gpui::Div> {
748 let tokens = cx.theme().semantic_tokens();
749 let focus = input.focus_handle(cx);
750 let decrement_input = input.clone();
751 let increment_input = input.clone();
752 let accessible_decrement_input = input.clone();
753 let accessible_increment_input = input.clone();
754 let accessible_value_input = input.clone();
755 div()
756 .id((gpui::ElementId::from(card_id.clone()), key))
757 .accessibility_id(format!("fine-tune.{card_id}.{key}"))
758 .debug_selector(move || format!("fine-tune-{key}-editor"))
759 .role(Role::SpinButton)
760 .aria_label(label)
761 .aria_numeric_value(semantics.value)
762 .aria_min_numeric_value(semantics.min)
763 .aria_max_numeric_value(semantics.max)
764 .aria_numeric_value_step(1.)
765 .aria_value(format!("{} {suffix}", semantics.value))
766 .track_focus(&focus)
767 .key_context("NumberInput")
768 .on_action(move |_: &Decrement, window, cx| {
769 step_numeric_editor(
770 &decrement_input,
771 StepAction::Decrement,
772 semantics,
773 window,
774 cx,
775 );
776 })
777 .on_action(move |_: &Increment, window, cx| {
778 step_numeric_editor(
779 &increment_input,
780 StepAction::Increment,
781 semantics,
782 window,
783 cx,
784 );
785 })
786 .on_a11y_action(AccessibleAction::Decrement, move |_, window, cx| {
787 step_numeric_editor(
788 &accessible_decrement_input,
789 StepAction::Decrement,
790 semantics,
791 window,
792 cx,
793 );
794 })
795 .on_a11y_action(AccessibleAction::Increment, move |_, window, cx| {
796 step_numeric_editor(
797 &accessible_increment_input,
798 StepAction::Increment,
799 semantics,
800 window,
801 cx,
802 );
803 })
804 .on_a11y_action(AccessibleAction::SetValue, move |data, window, cx| {
805 let Some(gpui::accesskit::ActionData::Value(value)) = data else {
806 return;
807 };
808 accessible_value_input.update(cx, |input, cx| {
809 input.replace_all(value.to_string(), window, cx)
810 });
811 })
812 .rounded(tokens.radius.sm)
813 .border_1()
814 .border_color(cx.theme().transparent)
815 .focus_visible(|style| style.border_color(cx.theme().ring))
816 .child(
817 Input::new(input)
818 .small()
819 .role(None::<Role>)
820 .suffix(crate::surface::field_unit(suffix, cx)),
821 )
822}
823
824fn opacity_slider_control(
825 card_id: &SharedString,
826 value: f32,
827 state: &Entity<SliderState>,
828 focus: &FocusHandle,
829 weak_card: gpui::WeakEntity<FineTuneCard>,
830 cx: &mut gpui::App,
831) -> gpui::Stateful<gpui::Div> {
832 let tokens = cx.theme().semantic_tokens();
833 let percentage = state.read(cx).percentage().end;
834 let mouse_focus = focus.clone();
835 let keyboard_card = weak_card.clone();
836 let decrement_card = weak_card.clone();
837 let increment_card = weak_card.clone();
838 let set_value_card = weak_card;
839 div()
840 .id((gpui::ElementId::from(card_id.clone()), "opacity-slider"))
841 .debug_selector(|| "fine-tune-opacity-slider".to_owned())
842 .accessibility_id(format!("fine-tune.{card_id}.opacity-slider"))
843 .role(Role::Slider)
844 .aria_label("Opacity slider")
845 .aria_numeric_value(f64::from(value * 100.))
846 .aria_min_numeric_value(0.)
847 .aria_max_numeric_value(100.)
848 .aria_numeric_value_step(1.)
849 .aria_value(format!("{} percent", value * 100.))
850 .aria_orientation(Orientation::Horizontal)
851 .track_focus(focus)
852 .tab_index(0)
853 .on_mouse_down(MouseButton::Left, move |_, window, cx| {
854 mouse_focus.focus(window, cx);
855 })
856 .on_key_down(move |event, window, cx| {
857 let step = match event.keystroke.key.as_str() {
858 "left" | "down" => -1.,
859 "right" | "up" => 1.,
860 _ => return,
861 };
862 let _ = keyboard_card.update(cx, |card, cx| {
863 card.step_opacity(step, window, cx);
864 });
865 cx.stop_propagation();
866 })
867 .on_a11y_action(AccessibleAction::Decrement, move |_, window, cx| {
868 let _ = decrement_card.update(cx, |card, cx| {
869 card.step_opacity(-1., window, cx);
870 });
871 })
872 .on_a11y_action(AccessibleAction::Increment, move |_, window, cx| {
873 let _ = increment_card.update(cx, |card, cx| {
874 card.step_opacity(1., window, cx);
875 });
876 })
877 .on_a11y_action(AccessibleAction::SetValue, move |data, window, cx| {
878 let Some(gpui::accesskit::ActionData::Value(value)) = data else {
879 return;
880 };
881 let Ok(value) = value.parse::<f32>() else {
882 return;
883 };
884 let _ = set_value_card.update(cx, |card, cx| {
885 card.change_opacity(value / 100., window, cx);
886 });
887 })
888 .relative()
889 .w_full()
890 .py(tokens.spacing.xs)
891 .border_1()
892 .border_color(cx.theme().transparent)
893 .rounded(tokens.radius.sm)
894 .focus_visible(|style| style.border_color(cx.theme().ring))
895 .child(
896 SliderTrack::new(state)
897 .relative()
898 .w_full()
899 .h(tokens.spacing.sm)
900 .child(
901 SliderIndicator::new(state)
902 .absolute()
903 .top(tokens.spacing.xs)
904 .left_0()
905 .w_full()
906 .h(tokens.spacing.xxs)
907 .rounded(tokens.radius.sm)
908 .bg(cx.theme().muted)
909 .child(
910 div()
911 .absolute()
912 .top_0()
913 .bottom_0()
914 .left_0()
915 .right(relative(1. - percentage))
916 .rounded(tokens.radius.sm)
917 .bg(cx.theme().primary),
918 ),
919 )
920 .child(
921 SliderThumb::new(state)
922 .absolute()
923 .top_0()
924 .left(relative(percentage))
925 .ml(-tokens.spacing.xs)
926 .size(tokens.spacing.sm)
927 .rounded(tokens.radius.full)
929 .border_1()
930 .border_color(cx.theme().primary)
931 .bg(cx.theme().background),
932 ),
933 )
934}
935
936fn numeric_field(
937 card_id: &SharedString,
938 key: &'static str,
939 label: &'static str,
940 semantics: NumericSemantics,
941 input: &Entity<InputState>,
942 suffix: &'static str,
943 cx: &mut gpui::App,
944) -> gpui::Stateful<gpui::Div> {
945 let tokens = cx.theme().semantic_tokens();
946 v_flex()
947 .id((gpui::ElementId::from(card_id.clone()), key))
948 .debug_selector(move || format!("fine-tune-{key}-input"))
949 .role(Role::Group)
950 .aria_label(format!("{label} property"))
951 .flex_1()
952 .min_w_0()
953 .gap(tokens.spacing.xs)
954 .child(Label::new(label).text_token(tokens.typography.sm))
955 .child(named_number_input(
956 card_id, key, label, semantics, input, suffix, cx,
957 ))
958}
959
960impl gpui::Styled for FineTuneCard {
961 fn style(&mut self) -> &mut gpui::StyleRefinement {
962 &mut self.style
963 }
964}
965
966impl Render for FineTuneCard {
967 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
968 let tokens = cx.theme().semantic_tokens();
969 let root_id = self.id.clone();
970 let selected_typeface = self
971 .typefaces
972 .iter()
973 .find(|typeface| typeface.id == self.local_typeface_id)
974 .map(|typeface| typeface.label.clone());
975 let typefaces_empty = self.typefaces.is_empty();
976 let selected_typeface_value: SharedString =
977 selected_typeface.clone().unwrap_or_else(|| {
978 if typefaces_empty {
979 "No typefaces available".into()
980 } else {
981 format!("Unavailable typeface: {}", self.local_typeface_id).into()
982 }
983 });
984 let selected_typeface =
985 selected_typeface.unwrap_or_else(|| selected_typeface_value.clone());
986 let typefaces = self.typefaces.clone();
987 let selected_typeface_id = self.local_typeface_id.clone();
988 let weak_card = cx.weak_entity();
989 let opacity_weak_card = cx.weak_entity();
990 let typeface_button =
991 Button::new((gpui::ElementId::from(self.id.clone()), "typeface-trigger"))
992 .label(selected_typeface)
993 .small()
994 .outline()
995 .disabled(typefaces_empty)
996 .debug_selector(|| "fine-tune-typeface".to_owned())
997 .dropdown_caret(!typefaces_empty);
998 let typeface_control = if typefaces_empty {
999 typeface_button.into_any_element()
1000 } else {
1001 typeface_button
1002 .dropdown_menu(move |menu, _, _| {
1003 typefaces.iter().fold(menu, |menu, typeface| {
1004 let typeface_id = typeface.id.clone();
1005 let callback_id = typeface_id.clone();
1006 let weak_card = weak_card.clone();
1007 menu.item(
1008 PopupMenuItem::new(typeface.label.clone())
1009 .checked(typeface_id == selected_typeface_id)
1010 .on_click(move |_, _, cx| {
1011 let _ = weak_card.update(cx, |card, cx| {
1012 card.select_typeface(callback_id.clone(), cx);
1013 });
1014 }),
1015 )
1016 })
1017 })
1018 .into_any_element()
1019 };
1020 let accent_text: SharedString = self
1021 .local_accent
1022 .map(|accent| accent.to_hex().into())
1023 .unwrap_or_else(|| "No accent selected".into());
1024 let has_accent = self.local_accent.is_some();
1025 let card = v_flex()
1026 .id(root_id.clone())
1027 .debug_selector(move || format!("fine-tune-card-{root_id}"))
1028 .role(Role::Group)
1029 .aria_label("Fine-tune design properties")
1030 .min_h_0()
1031 .h_full()
1032 .max_h_full()
1033 .overflow_y_scroll()
1034 .track_scroll(&self.scroll_handle)
1035 .gap(tokens.spacing.md)
1036 .p(tokens.spacing.md)
1037 .rounded(tokens.radius.lg)
1038 .border_1()
1039 .border_color(cx.theme().border)
1040 .bg(tokens.colors.surface)
1041 .child(
1042 h_flex()
1043 .w_full()
1044 .gap(tokens.spacing.sm)
1045 .child(numeric_field(
1046 &self.id,
1047 "width",
1048 "Width",
1049 NumericSemantics {
1050 value: self.local_width,
1051 min: MIN_DIMENSION,
1052 max: MAX_DIMENSION,
1053 },
1054 &self.width_input,
1055 "px",
1056 cx,
1057 ))
1058 .child(numeric_field(
1059 &self.id,
1060 "height",
1061 "Height",
1062 NumericSemantics {
1063 value: self.local_height,
1064 min: MIN_DIMENSION,
1065 max: MAX_DIMENSION,
1066 },
1067 &self.height_input,
1068 "px",
1069 cx,
1070 )),
1071 )
1072 .child(numeric_field(
1073 &self.id,
1074 "radius",
1075 "Radius",
1076 NumericSemantics {
1077 value: self.local_radius,
1078 min: MIN_RADIUS,
1079 max: MAX_RADIUS,
1080 },
1081 &self.radius_input,
1082 "px",
1083 cx,
1084 ))
1085 .child(
1086 v_flex()
1087 .id((gpui::ElementId::from(self.id.clone()), "opacity"))
1088 .role(Role::Group)
1089 .aria_label("Opacity property")
1090 .gap(tokens.spacing.xs)
1091 .child(
1092 h_flex()
1093 .items_end()
1094 .gap(tokens.spacing.sm)
1095 .child(
1096 Label::new("Opacity")
1097 .text_token(tokens.typography.sm)
1098 .flex_1(),
1099 )
1100 .child(div().w(tokens.spacing.xxl * 2.).child(named_number_input(
1101 &self.id,
1102 "opacity-input",
1103 "Opacity",
1104 NumericSemantics {
1105 value: f64::from(self.local_opacity * 100.),
1106 min: 0.,
1107 max: 100.,
1108 },
1109 &self.opacity_input,
1110 "%",
1111 cx,
1112 ))),
1113 )
1114 .child(opacity_slider_control(
1115 &self.id,
1116 self.local_opacity,
1117 &self.opacity_slider,
1118 &self.opacity_slider_focus,
1119 opacity_weak_card,
1120 cx,
1121 )),
1122 )
1123 .child(
1124 v_flex()
1125 .id((gpui::ElementId::from(self.id.clone()), "typeface"))
1126 .role(Role::Group)
1127 .aria_label("Typeface selection")
1128 .aria_value(selected_typeface_value)
1129 .gap(tokens.spacing.xs)
1130 .child(Label::new("Typeface").text_token(tokens.typography.sm))
1131 .child(typeface_control),
1132 )
1133 .child(
1134 v_flex()
1135 .id((gpui::ElementId::from(self.id.clone()), "accent"))
1136 .role(Role::Group)
1137 .aria_label("Accent color")
1138 .aria_value(accent_text.clone())
1139 .gap(tokens.spacing.xs)
1140 .child(Label::new("Accent").text_token(tokens.typography.sm))
1141 .child(
1142 h_flex()
1143 .w_full()
1144 .flex_wrap()
1145 .gap(tokens.spacing.sm)
1146 .items_center()
1147 .child(
1148 ColorPicker::new(&self.accent_picker)
1149 .small()
1150 .label("Accent color"),
1151 )
1152 .child(
1153 Label::new(accent_text)
1154 .text_token(tokens.typography.sm)
1155 .flex_1(),
1156 )
1157 .when(has_accent, |this| {
1158 this.child(
1159 outlined_control(
1160 (gpui::ElementId::from(self.id.clone()), "clear-accent"),
1161 "Remove accent color",
1162 window,
1163 cx,
1164 )
1165 .debug_selector(|| "fine-tune-clear-accent".to_owned())
1166 .on_click(cx.listener(
1167 |card, _, window, cx| {
1168 card.change_accent(None, window, cx);
1169 },
1170 )),
1171 )
1172 }),
1173 ),
1174 )
1175 .child(
1176 h_flex()
1177 .w_full()
1178 .flex_wrap()
1179 .justify_end()
1180 .gap(tokens.spacing.xs)
1181 .child(
1182 outlined_control_with_label(
1183 (gpui::ElementId::from(self.id.clone()), "reset"),
1184 "Reset fine-tune values",
1185 "Reset",
1186 window,
1187 cx,
1188 )
1189 .debug_selector(|| "fine-tune-reset".to_owned())
1190 .on_click(cx.listener(|this, _, _, cx| {
1191 cx.emit(FineTuneEvent::ResetRequested {
1192 id: this.id.clone(),
1193 });
1194 })),
1195 )
1196 .child(
1197 outlined_control_with_label(
1198 (gpui::ElementId::from(self.id.clone()), "apply"),
1199 "Apply fine-tune values",
1200 "Apply",
1201 window,
1202 cx,
1203 )
1204 .debug_selector(|| "fine-tune-apply".to_owned())
1205 .on_click(cx.listener(|this, _, _, cx| {
1206 cx.emit(FineTuneEvent::ApplyRequested {
1207 id: this.id.clone(),
1208 });
1209 })),
1210 ),
1211 )
1212 .child(div().hidden());
1213
1214 div()
1215 .relative()
1216 .size_full()
1217 .child(card)
1218 .child(
1219 ScrollableMask::new(Axis::Vertical, &self.scroll_handle).id((
1220 gpui::ElementId::from(self.id.clone()),
1221 "content-scroll-mask",
1222 )),
1223 )
1224 .refine_style(&self.style)
1225 }
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230 use super::*;
1231 use gpui::{Element as _, TestAppContext, accesskit, canvas};
1232 use std::{
1233 cell::{Cell, RefCell},
1234 rc::Rc,
1235 sync::{Arc, Mutex},
1236 };
1237
1238 type CapturedSemanticNode = Arc<Mutex<Option<(Option<Role>, accesskit::Node)>>>;
1239
1240 struct NumericSemanticProbe {
1241 input: Entity<InputState>,
1242 captured: CapturedSemanticNode,
1243 }
1244
1245 struct SliderSemanticProbe {
1246 card: Entity<FineTuneCard>,
1247 captured: CapturedSemanticNode,
1248 }
1249
1250 impl SliderSemanticProbe {
1251 fn new(
1252 captured: CapturedSemanticNode,
1253 window: &mut Window,
1254 cx: &mut Context<Self>,
1255 ) -> Self {
1256 let card = cx.new(|cx| {
1257 FineTuneCard::new(
1258 "fine-tune",
1259 FineTuneValues::new(320., 180., 24., 0.72, "inter"),
1260 [FineTuneTypeface::new("inter", "Inter")],
1261 window,
1262 cx,
1263 )
1264 });
1265 Self { card, captured }
1266 }
1267 }
1268
1269 impl Render for SliderSemanticProbe {
1270 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1271 let card = self.card.clone();
1272 let captured = self.captured.clone();
1273 canvas(
1274 move |_, _, cx| {
1275 let (id, value, state, focus) = card.read_with(cx, |card, _| {
1276 (
1277 card.id.clone(),
1278 card.local_opacity,
1279 card.opacity_slider.clone(),
1280 card.opacity_slider_focus.clone(),
1281 )
1282 });
1283 let slider =
1284 opacity_slider_control(&id, value, &state, &focus, card.downgrade(), cx)
1285 .into_element();
1286 let role = slider.a11y_role();
1287 let mut node = accesskit::Node::new(Role::Unknown);
1288 slider.write_a11y_info(&mut node);
1289 *captured.lock().expect("capture mutex should be available") =
1290 Some((role, node));
1291 },
1292 |_, _, _, _| {},
1293 )
1294 }
1295 }
1296
1297 impl NumericSemanticProbe {
1298 fn new(
1299 captured: CapturedSemanticNode,
1300 window: &mut Window,
1301 cx: &mut Context<Self>,
1302 ) -> Self {
1303 let input = cx.new(|cx| InputState::new(window, cx).default_value("320"));
1304 Self { input, captured }
1305 }
1306 }
1307
1308 impl Render for NumericSemanticProbe {
1309 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1310 let input = self.input.clone();
1311 let captured = self.captured.clone();
1312 canvas(
1313 move |_, _, cx| {
1314 let field = named_number_input(
1315 &"fine-tune".into(),
1316 "width",
1317 "Width",
1318 NumericSemantics {
1319 value: 320.,
1320 min: MIN_DIMENSION,
1321 max: MAX_DIMENSION,
1322 },
1323 &input,
1324 "px",
1325 cx,
1326 )
1327 .into_element();
1328 let role = field.a11y_role();
1329 let mut node = accesskit::Node::new(Role::Unknown);
1330 field.write_a11y_info(&mut node);
1331 *captured.lock().expect("capture mutex should be available") =
1332 Some((role, node));
1333 },
1334 |_, _, _, _| {},
1335 )
1336 }
1337 }
1338
1339 #[test]
1340 fn controlled_values_clamp_numeric_properties_and_normalize_opacity() {
1341 let values = FineTuneValues::new(9_000., -20., 3_000., 1.4, "inter");
1342
1343 assert_eq!(values.width(), 4_096.);
1344 assert_eq!(values.height(), 1.);
1345 assert_eq!(values.radius(), 2_048.);
1346 assert_eq!(values.opacity(), 1.);
1347 assert_eq!(values.typeface_id(), "inter");
1348 }
1349
1350 #[gpui::test]
1351 fn numeric_editor_exposes_name_value_bounds_step_and_actions(cx: &mut TestAppContext) {
1352 cx.update(crate::init);
1353 let captured = Arc::new(Mutex::new(None));
1354 let result = captured.clone();
1355 let (_, cx) =
1356 cx.add_window_view(move |window, cx| NumericSemanticProbe::new(captured, window, cx));
1357 cx.update(|window, cx| window.draw(cx).clear(cx));
1358
1359 let (role, node) = result
1360 .lock()
1361 .expect("capture mutex should be available")
1362 .take()
1363 .expect("numeric property semantics should be captured");
1364 assert_eq!(role, Some(Role::SpinButton));
1365 assert_eq!(node.label(), Some("Width"));
1366 assert_eq!(node.value(), Some("320 px"));
1367 assert_eq!(node.numeric_value(), Some(320.));
1368 assert_eq!(node.min_numeric_value(), Some(MIN_DIMENSION));
1369 assert_eq!(node.max_numeric_value(), Some(MAX_DIMENSION));
1370 assert_eq!(node.numeric_value_step(), Some(1.));
1371 assert!(node.supports_action(AccessibleAction::Increment));
1372 assert!(node.supports_action(AccessibleAction::Decrement));
1373 assert!(node.supports_action(AccessibleAction::SetValue));
1374 }
1375
1376 #[gpui::test]
1377 fn opacity_slider_exposes_name_value_bounds_step_and_actions(cx: &mut TestAppContext) {
1378 cx.update(crate::init);
1379 let captured = Arc::new(Mutex::new(None));
1380 let result = captured.clone();
1381 let (probe, cx) =
1382 cx.add_window_view(move |window, cx| SliderSemanticProbe::new(captured, window, cx));
1383 cx.update(|window, cx| window.draw(cx).clear(cx));
1384
1385 let (role, node) = result
1386 .lock()
1387 .expect("capture mutex should be available")
1388 .take()
1389 .expect("opacity slider semantics should be captured");
1390 assert_eq!(role, Some(Role::Slider));
1391 assert_eq!(node.label(), Some("Opacity slider"));
1392 assert_eq!(node.value(), Some("72 percent"));
1393 assert_eq!(node.numeric_value(), Some(72.));
1394 assert_eq!(node.min_numeric_value(), Some(0.));
1395 assert_eq!(node.max_numeric_value(), Some(100.));
1396 assert_eq!(node.numeric_value_step(), Some(1.));
1397 assert!(node.supports_action(AccessibleAction::Increment));
1398 assert!(node.supports_action(AccessibleAction::Decrement));
1399 assert!(node.supports_action(AccessibleAction::SetValue));
1400
1401 let card = probe.read_with(cx, |probe, _| probe.card.clone());
1402 cx.update(|window, cx| {
1403 card.update(cx, |card, cx| card.step_opacity(1., window, cx));
1404 let (id, value, state, focus) = card.read_with(cx, |card, _| {
1405 (
1406 card.id.clone(),
1407 card.local_opacity,
1408 card.opacity_slider.clone(),
1409 card.opacity_slider_focus.clone(),
1410 )
1411 });
1412 let slider = opacity_slider_control(&id, value, &state, &focus, card.downgrade(), cx)
1413 .into_element();
1414 let role = slider.a11y_role();
1415 let mut node = accesskit::Node::new(Role::Unknown);
1416 slider.write_a11y_info(&mut node);
1417 *result.lock().expect("capture mutex should be available") = Some((role, node));
1418 });
1419 let (_, node) = result
1420 .lock()
1421 .expect("capture mutex should be available")
1422 .take()
1423 .expect("locally edited opacity semantics should be captured before consumer echo");
1424 assert_eq!(node.value(), Some("73 percent"));
1425 assert_eq!(node.numeric_value(), Some(73.));
1426 assert_eq!(card.read_with(cx, |card, _| card.values.opacity), 0.72);
1427 }
1428
1429 #[gpui::test]
1430 fn invalid_intermediate_number_does_not_emit_a_semantic_change(cx: &mut TestAppContext) {
1431 cx.update(crate::init);
1432 let (card, cx) = cx.add_window_view(|window, cx| {
1433 FineTuneCard::new(
1434 "fine-tune",
1435 FineTuneValues::new(320., 180., 24., 0.72, "inter"),
1436 [FineTuneTypeface::new("inter", "Inter")],
1437 window,
1438 cx,
1439 )
1440 });
1441 let events = Rc::new(RefCell::new(Vec::new()));
1442 let captured = events.clone();
1443 let _subscription = cx.update(|_, cx| {
1444 cx.subscribe(&card, move |_, event, _| {
1445 captured.borrow_mut().push(event.clone());
1446 })
1447 });
1448 let input = card.read_with(cx, |card, _| card.width_input.clone());
1449
1450 cx.update(|window, cx| {
1451 input.update(cx, |input, cx| input.replace_all("-", window, cx));
1452 });
1453 cx.run_until_parked();
1454
1455 assert!(events.borrow().is_empty());
1456 }
1457
1458 #[gpui::test]
1459 fn numeric_editor_events_are_clamped_typed_and_card_identified(cx: &mut TestAppContext) {
1460 cx.update(crate::init);
1461 let (card, cx) = cx.add_window_view(|window, cx| {
1462 FineTuneCard::new(
1463 "fine-tune",
1464 FineTuneValues::new(320., 180., 24., 0.72, "inter"),
1465 [FineTuneTypeface::new("inter", "Inter")],
1466 window,
1467 cx,
1468 )
1469 });
1470 let events = Rc::new(RefCell::new(Vec::new()));
1471 let captured = events.clone();
1472 let _subscription = cx.update(|_, cx| {
1473 cx.subscribe(&card, move |_, event, _| {
1474 captured.borrow_mut().push(event.clone());
1475 })
1476 });
1477 let (width, height, radius, opacity) = card.read_with(cx, |card, _| {
1478 (
1479 card.width_input.clone(),
1480 card.height_input.clone(),
1481 card.radius_input.clone(),
1482 card.opacity_input.clone(),
1483 )
1484 });
1485
1486 cx.update(|window, cx| {
1487 width.update(cx, |input, cx| input.replace_all("9000", window, cx));
1488 height.update(cx, |input, cx| input.replace_all("-20", window, cx));
1489 radius.update(cx, |input, cx| input.replace_all("3000", window, cx));
1490 opacity.update(cx, |input, cx| input.replace_all("125", window, cx));
1491 });
1492 cx.run_until_parked();
1493
1494 assert_eq!(
1495 events.borrow().as_slice(),
1496 [
1497 FineTuneEvent::WidthChanged {
1498 id: "fine-tune".into(),
1499 width: 4_096.,
1500 },
1501 FineTuneEvent::HeightChanged {
1502 id: "fine-tune".into(),
1503 height: 1.,
1504 },
1505 FineTuneEvent::RadiusChanged {
1506 id: "fine-tune".into(),
1507 radius: 2_048.,
1508 },
1509 FineTuneEvent::OpacityChanged {
1510 id: "fine-tune".into(),
1511 opacity: 1.,
1512 },
1513 ]
1514 );
1515 }
1516
1517 #[gpui::test]
1518 fn controlled_replacement_preserves_valid_editor_text_and_identity(cx: &mut TestAppContext) {
1519 cx.update(crate::init);
1520 let (card, cx) = cx.add_window_view(|window, cx| {
1521 FineTuneCard::new(
1522 "fine-tune",
1523 FineTuneValues::new(320., 180., 24., 0.72, "inter"),
1524 [FineTuneTypeface::new("inter", "Inter")],
1525 window,
1526 cx,
1527 )
1528 });
1529 let (width, width_id, height_id, radius_id, opacity_id) = card.read_with(cx, |card, _| {
1530 (
1531 card.width_input.clone(),
1532 card.width_input.entity_id(),
1533 card.height_input.entity_id(),
1534 card.radius_input.entity_id(),
1535 card.opacity_input.entity_id(),
1536 )
1537 });
1538 cx.update(|window, cx| {
1539 width.update(cx, |input, cx| {
1540 input.replace_all("480.", window, cx);
1541 });
1542 card.update(cx, |card, cx| {
1543 card.set_values(
1544 FineTuneValues::new(480., 240., 12., 0.5, "inter"),
1545 window,
1546 cx,
1547 );
1548 });
1549 });
1550
1551 card.read_with(cx, |card, cx| {
1552 assert_eq!(card.width_input.entity_id(), width_id);
1553 assert_eq!(card.height_input.entity_id(), height_id);
1554 assert_eq!(card.radius_input.entity_id(), radius_id);
1555 assert_eq!(card.opacity_input.entity_id(), opacity_id);
1556 assert_eq!(card.width_input.read(cx).value(), "480.");
1557 assert_eq!(card.height_input.read(cx).value(), "240");
1558 assert_eq!(card.radius_input.read(cx).value(), "12");
1559 assert_eq!(card.opacity_input.read(cx).value(), "50");
1560 assert_eq!(card.opacity_slider.read(cx).value().end(), 50.);
1561 });
1562
1563 cx.update(|window, cx| {
1564 width.update(cx, |input, cx| input.replace_all("9000", window, cx));
1565 card.update(cx, |card, cx| {
1566 card.set_values(
1567 FineTuneValues::new(4_096., 240., 12., 0.5, "inter"),
1568 window,
1569 cx,
1570 );
1571 });
1572 });
1573 assert_eq!(width.read_with(cx, |input, _| input.value()), "4096");
1574 }
1575
1576 #[gpui::test]
1577 fn typeface_and_accent_changes_emit_stable_identity_once(cx: &mut TestAppContext) {
1578 cx.update(crate::init);
1579 let (card, cx) = cx.add_window_view(|window, cx| {
1580 FineTuneCard::new(
1581 "fine-tune",
1582 FineTuneValues::new(320., 180., 24., 0.72, "inter-regular"),
1583 [
1584 FineTuneTypeface::new("inter-regular", "Inter"),
1585 FineTuneTypeface::new("inter-display", "Inter"),
1586 ],
1587 window,
1588 cx,
1589 )
1590 });
1591 let events = Rc::new(RefCell::new(Vec::new()));
1592 let captured = events.clone();
1593 let _subscription = cx.update(|_, cx| {
1594 cx.subscribe(&card, move |_, event, _| {
1595 captured.borrow_mut().push(event.clone());
1596 })
1597 });
1598 let accent = gpui::hsla(0.58, 0.75, 0.52, 1.);
1599
1600 cx.update(|window, cx| {
1601 card.update(cx, |card, cx| {
1602 card.select_typeface("inter-display", cx);
1603 card.select_typeface("inter-display", cx);
1604 card.change_accent(Some(accent), window, cx);
1605 card.change_accent(Some(accent), window, cx);
1606 });
1607 });
1608
1609 assert_eq!(
1610 events.borrow().as_slice(),
1611 [
1612 FineTuneEvent::TypefaceChanged {
1613 id: "fine-tune".into(),
1614 typeface_id: "inter-display".into(),
1615 },
1616 FineTuneEvent::AccentChanged {
1617 id: "fine-tune".into(),
1618 accent: Some(accent),
1619 },
1620 ]
1621 );
1622 }
1623
1624 #[gpui::test]
1625 fn unchanged_consumer_snapshot_rejects_local_typeface_and_notifies(cx: &mut TestAppContext) {
1626 cx.update(crate::init);
1627 let snapshot = FineTuneValues::new(320., 180., 24., 0.72, "inter-regular");
1628 let (card, cx) = cx.add_window_view({
1629 let snapshot = snapshot.clone();
1630 move |window, cx| {
1631 FineTuneCard::new(
1632 "fine-tune",
1633 snapshot,
1634 [
1635 FineTuneTypeface::new("inter-regular", "Inter Regular"),
1636 FineTuneTypeface::new("inter-display", "Inter Display"),
1637 ],
1638 window,
1639 cx,
1640 )
1641 }
1642 });
1643 let notifications = Rc::new(Cell::new(0));
1644 let captured = notifications.clone();
1645 let _observation =
1646 cx.update(|_, cx| cx.observe(&card, move |_, _| captured.set(captured.get() + 1)));
1647
1648 cx.update(|_, cx| {
1649 card.update(cx, |card, cx| card.select_typeface("inter-display", cx));
1650 });
1651 cx.run_until_parked();
1652 let before_rejection = notifications.get();
1653
1654 cx.update(|window, cx| {
1655 card.update(cx, |card, cx| card.set_values(snapshot, window, cx));
1656 });
1657 cx.run_until_parked();
1658
1659 assert_eq!(
1660 card.read_with(cx, |card, _| card.local_typeface_id.clone()),
1661 "inter-regular"
1662 );
1663 assert!(notifications.get() > before_rejection);
1664 }
1665
1666 #[gpui::test]
1667 fn retained_slider_and_picker_subscriptions_emit_typed_changes_once(cx: &mut TestAppContext) {
1668 cx.update(crate::init);
1669 let (card, cx) = cx.add_window_view(|window, cx| {
1670 FineTuneCard::new(
1671 "fine-tune",
1672 FineTuneValues::new(320., 180., 24., 0.72, "inter"),
1673 [FineTuneTypeface::new("inter", "Inter")],
1674 window,
1675 cx,
1676 )
1677 });
1678 let events = Rc::new(RefCell::new(Vec::new()));
1679 let captured = events.clone();
1680 let _subscription = cx.update(|_, cx| {
1681 cx.subscribe(&card, move |_, event, _| {
1682 captured.borrow_mut().push(event.clone());
1683 })
1684 });
1685 let (slider, picker) = card.read_with(cx, |card, _| {
1686 (card.opacity_slider.clone(), card.accent_picker.clone())
1687 });
1688 let accent = gpui::hsla(0.08, 0.82, 0.56, 1.);
1689
1690 cx.update(|_, cx| {
1691 slider.update(cx, |_, cx| cx.emit(SliderEvent::Change(75_f32.into())));
1692 slider.update(cx, |_, cx| cx.emit(SliderEvent::Release(75_f32.into())));
1693 picker.update(cx, |_, cx| cx.emit(ColorPickerEvent::Change(Some(accent))));
1694 });
1695
1696 assert_eq!(
1697 events.borrow().as_slice(),
1698 [
1699 FineTuneEvent::OpacityChanged {
1700 id: "fine-tune".into(),
1701 opacity: 0.75,
1702 },
1703 FineTuneEvent::AccentChanged {
1704 id: "fine-tune".into(),
1705 accent: Some(accent),
1706 },
1707 ]
1708 );
1709 }
1710
1711 #[gpui::test]
1712 fn malformed_typeface_replacement_is_ignored(cx: &mut TestAppContext) {
1713 cx.update(crate::init);
1714 let (card, cx) = cx.add_window_view(|window, cx| {
1715 FineTuneCard::new(
1716 "fine-tune",
1717 FineTuneValues::new(320., 180., 24., 0.72, "inter"),
1718 [FineTuneTypeface::new("inter", "Inter")],
1719 window,
1720 cx,
1721 )
1722 });
1723
1724 cx.update(|_, cx| {
1725 card.update(cx, |card, cx| {
1726 card.set_typefaces(
1727 [
1728 FineTuneTypeface::new("duplicate", "Inter"),
1729 FineTuneTypeface::new("duplicate", "Display"),
1730 ],
1731 cx,
1732 );
1733 });
1734 });
1735
1736 card.read_with(cx, |card, _| {
1737 assert_eq!(
1738 card.typefaces.as_ref(),
1739 [FineTuneTypeface::new("inter", "Inter")]
1740 );
1741 });
1742 }
1743}