Skip to main content

gpui_ai/
fine_tune.rs

1//! Controlled fine-tune inspector for design properties.
2
3use 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/// Consumer-owned design values displayed by a [`FineTuneCard`].
56#[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    /// Create a normalized snapshot without an accent color.
68    ///
69    /// Dimensions clamp to `1..=4096`, radius to `0..=2048`, and opacity to
70    /// the normalized `0..=1` range.
71    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    /// Set the optional accent color.
89    pub fn accent(mut self, accent: Hsla) -> Self {
90        self.accent = Some(accent);
91        self
92    }
93
94    /// Return a snapshot with a different normalized width.
95    pub fn with_width(mut self, width: f64) -> Self {
96        self.width = finite_clamp(width, MIN_DIMENSION, MAX_DIMENSION);
97        self
98    }
99
100    /// Return a snapshot with a different normalized height.
101    pub fn with_height(mut self, height: f64) -> Self {
102        self.height = finite_clamp(height, MIN_DIMENSION, MAX_DIMENSION);
103        self
104    }
105
106    /// Return a snapshot with a different normalized radius.
107    pub fn with_radius(mut self, radius: f64) -> Self {
108        self.radius = finite_clamp(radius, MIN_RADIUS, MAX_RADIUS);
109        self
110    }
111
112    /// Return a snapshot with a different normalized opacity.
113    pub fn with_opacity(mut self, opacity: f32) -> Self {
114        self.opacity = normalize_opacity(opacity);
115        self
116    }
117
118    /// Return a snapshot with a different stable typeface identity.
119    pub fn with_typeface(mut self, typeface_id: impl Into<SharedString>) -> Self {
120        self.typeface_id = typeface_id.into();
121        self
122    }
123
124    /// Return a snapshot with a different optional accent color.
125    pub fn with_accent(mut self, accent: Option<Hsla>) -> Self {
126        self.accent = accent;
127        self
128    }
129
130    /// Return the clamped width in logical pixels.
131    pub fn width(&self) -> f64 {
132        self.width
133    }
134
135    /// Return the clamped height in logical pixels.
136    pub fn height(&self) -> f64 {
137        self.height
138    }
139
140    /// Return the clamped corner radius in logical pixels.
141    pub fn radius(&self) -> f64 {
142        self.radius
143    }
144
145    /// Return normalized opacity in `0..=1`.
146    pub fn opacity(&self) -> f32 {
147        self.opacity
148    }
149
150    /// Return the stable selected typeface identity.
151    pub fn typeface_id(&self) -> &SharedString {
152        &self.typeface_id
153    }
154
155    /// Return the optional accent color.
156    pub fn accent_color(&self) -> Option<Hsla> {
157        self.accent
158    }
159}
160
161/// One application-owned typeface choice.
162///
163/// IDs must be stable and unique in a snapshot. Labels may be duplicated.
164#[derive(Clone, Debug, PartialEq, Eq)]
165pub struct FineTuneTypeface {
166    id: SharedString,
167    label: SharedString,
168}
169
170impl FineTuneTypeface {
171    /// Create a typeface choice with stable identity and a visible label.
172    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    /// Return the stable application identity.
180    pub fn id(&self) -> &SharedString {
181        &self.id
182    }
183
184    /// Return the visible label.
185    pub fn label(&self) -> &SharedString {
186        &self.label
187    }
188}
189
190/// A typed application intent emitted by [`FineTuneCard`].
191#[derive(Clone, Debug, PartialEq)]
192pub enum FineTuneEvent {
193    /// The width editor proposed a different clamped value.
194    WidthChanged {
195        /// Fine-tune card identity.
196        id: SharedString,
197        /// Width in logical pixels.
198        width: f64,
199    },
200    /// The height editor proposed a different clamped value.
201    HeightChanged {
202        /// Fine-tune card identity.
203        id: SharedString,
204        /// Height in logical pixels.
205        height: f64,
206    },
207    /// The radius editor proposed a different clamped value.
208    RadiusChanged {
209        /// Fine-tune card identity.
210        id: SharedString,
211        /// Corner radius in logical pixels.
212        radius: f64,
213    },
214    /// The opacity editor or slider proposed a normalized value.
215    OpacityChanged {
216        /// Fine-tune card identity.
217        id: SharedString,
218        /// Normalized opacity in `0..=1`.
219        opacity: f32,
220    },
221    /// The typeface menu proposed a different stable typeface identity.
222    TypefaceChanged {
223        /// Fine-tune card identity.
224        id: SharedString,
225        /// Stable application typeface identity.
226        typeface_id: SharedString,
227    },
228    /// The accent picker proposed a different optional color.
229    AccentChanged {
230        /// Fine-tune card identity.
231        id: SharedString,
232        /// The selected accent, or `None` when cleared.
233        accent: Option<Hsla>,
234    },
235    /// The named Reset control was activated.
236    ResetRequested {
237        /// Fine-tune card identity.
238        id: SharedString,
239    },
240    /// The named Apply control was activated.
241    ApplyRequested {
242        /// Fine-tune card identity.
243        id: SharedString,
244    },
245}
246
247impl FineTuneEvent {
248    /// Return the stable identity of the card that emitted this intent.
249    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
271/// A consumer-controlled design-property inspector.
272pub struct FineTuneCard {
273    /// Styles the caller put on this component, applied to its own frame.
274    ///
275    /// Last, so a caller outranks the component's defaults - the same rule the
276    /// builder components follow. A wrapper `div` cannot stand in for this:
277    /// a background, a border, or an ink set on a wrapper paints around the
278    /// component rather than on it.
279    style: gpui::StyleRefinement,
280    id: SharedString,
281    values: FineTuneValues,
282    typefaces: Arc<[FineTuneTypeface]>,
283    /// Scroll position of the card body when its height is constrained.
284    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    /// Create a controlled card from a value snapshot and typeface catalog.
303    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    /// Replace the consumer-controlled value snapshot without rebuilding editors.
423    ///
424    /// A numeric editor keeps its exact text when that text is valid and
425    /// semantically equal to the incoming value. This preserves focused caret
426    /// and undo state while other editors synchronize to the new snapshot.
427    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    /// Replace the application typeface catalog without rebuilding editor state.
497    ///
498    /// Catalogs containing an empty or duplicate stable ID are ignored as one
499    /// malformed snapshot. Duplicate visible labels are supported.
500    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        // Preserving every valid semantically equal textual form also covers
532        // focused editors without needing to disturb their caret or undo state.
533        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                        // A thumb is a dot, and dots are circles here.
928                        .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}