Skip to main content

gpui_rhai/
column_resize.rs

1//! Native hot-lane column resize handle used by the Rhai Table component.
2
3use std::cell::RefCell;
4use std::collections::BTreeMap;
5use std::rc::Rc;
6
7use gpui::{
8    AnyElement, App, Bounds, CursorStyle, DispatchPhase, Element, ElementId, GlobalElementId,
9    Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId, MouseButton, MouseDownEvent,
10    Pixels, Point, SharedString, Style, Window, fill, point, px, relative, rgba, size,
11};
12
13use crate::{
14    ComponentStateSchema, EventSchema, Length, ObjectField, PrimitiveContext, PrimitiveDescriptor,
15    PrimitiveHandler, PrimitiveId, PrimitiveInstance, PrimitiveInstanceId, PrimitiveProps,
16    PrimitiveTheme, PrimitiveValue, Rgba8, SignalId, SignalKind, SignalValue, TextDirection,
17    UiValue, ValueSchema,
18};
19
20const DEFAULT_MIN_WIDTH: f64 = 48.0;
21const MAX_COLUMN_WIDTH: f64 = 16_384.0;
22
23#[derive(Clone, Default)]
24pub(crate) struct ColumnMeasurementRegistry(Rc<RefCell<BTreeMap<SignalId, BTreeMap<u64, f64>>>>);
25
26impl ColumnMeasurementRegistry {
27    fn report(&self, group: &SignalId, instance: u64, width: f64) {
28        self.0
29            .borrow_mut()
30            .entry(group.clone())
31            .or_default()
32            .insert(instance, width);
33    }
34
35    fn remove(&self, group: &SignalId, instance: u64) {
36        let mut groups = self.0.borrow_mut();
37        let remove_group = groups.get_mut(group).is_some_and(|measurements| {
38            measurements.remove(&instance);
39            measurements.is_empty()
40        });
41        if remove_group {
42            groups.remove(group);
43        }
44    }
45
46    fn maximum(&self, group: &SignalId) -> Option<f64> {
47        self.0
48            .borrow()
49            .get(group)
50            .and_then(|measurements| measurements.values().copied().reduce(f64::max))
51    }
52}
53
54#[derive(Clone, Debug, PartialEq)]
55struct SourceWidth {
56    column_key: String,
57    kind: String,
58    value: f64,
59    min: f64,
60    max: f64,
61}
62
63#[derive(Clone)]
64struct ResizeConfig {
65    id: String,
66    column_key: String,
67    signal: crate::NativeSignal,
68    reference: crate::ElementRef,
69    source: SourceWidth,
70    min: f64,
71    max: f64,
72    direction: TextDirection,
73    idle_color: Rgba8,
74    active_color: Rgba8,
75}
76
77#[derive(Clone)]
78struct ResizeState(Rc<RefCell<ResizeStateInner>>);
79
80#[derive(Clone)]
81struct ResizeStateInner {
82    source: SourceWidth,
83    signal: crate::SignalId,
84}
85
86impl ResizeState {
87    fn new(config: &ResizeConfig) -> Self {
88        Self(Rc::new(RefCell::new(ResizeStateInner {
89            source: config.source.clone(),
90            signal: config.signal.id().clone(),
91        })))
92    }
93}
94
95struct ResizePrepaint {
96    hitbox: Hitbox,
97}
98
99struct ColumnResizeHandle {
100    config: ResizeConfig,
101    events: PrimitiveContext,
102    measurements: ColumnMeasurementRegistry,
103}
104
105impl IntoElement for ColumnResizeHandle {
106    type Element = Self;
107
108    fn into_element(self) -> Self::Element {
109        self
110    }
111}
112
113impl Element for ColumnResizeHandle {
114    type RequestLayoutState = ();
115    type PrepaintState = ResizePrepaint;
116
117    fn id(&self) -> Option<ElementId> {
118        Some(ElementId::Name(self.config.id.clone().into()))
119    }
120
121    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
122        None
123    }
124
125    fn request_layout(
126        &mut self,
127        _: Option<&GlobalElementId>,
128        _: Option<&InspectorElementId>,
129        window: &mut Window,
130        cx: &mut App,
131    ) -> (LayoutId, Self::RequestLayoutState) {
132        let style = Style {
133            size: size(relative(1.0).into(), relative(1.0).into()),
134            ..Style::default()
135        };
136        (window.request_layout(style, None, cx), ())
137    }
138
139    fn prepaint(
140        &mut self,
141        _: Option<&GlobalElementId>,
142        _: Option<&InspectorElementId>,
143        bounds: Bounds<Pixels>,
144        (): &mut Self::RequestLayoutState,
145        window: &mut Window,
146        cx: &mut App,
147    ) -> Self::PrepaintState {
148        let state = window
149            .use_state(cx, |_, _| ResizeState::new(&self.config))
150            .read(cx)
151            .clone();
152        let reset = {
153            let mut inner = state.0.borrow_mut();
154            let changed =
155                inner.source != self.config.source || inner.signal != *self.config.signal.id();
156            if changed {
157                inner.source = self.config.source.clone();
158                inner.signal = self.config.signal.id().clone();
159            }
160            changed
161        };
162        let owner = self.events.interaction_owner(&self.config.id);
163        if reset && !self.events.cancel_interaction(&owner, window, cx) {
164            let events = self.events.clone();
165            let signal = self.config.signal.clone();
166            window.defer(cx, move |_, cx| {
167                let _ = events.write_signal(&signal, SignalValue::OptionalFloat(None), cx);
168            });
169        }
170        let hitbox = window.insert_hitbox(bounds, HitboxBehavior::BlockMouseExceptScroll);
171        ResizePrepaint { hitbox }
172    }
173
174    fn paint(
175        &mut self,
176        _: Option<&GlobalElementId>,
177        _: Option<&InspectorElementId>,
178        bounds: Bounds<Pixels>,
179        (): &mut Self::RequestLayoutState,
180        prepaint: &mut Self::PrepaintState,
181        window: &mut Window,
182        _: &mut App,
183    ) {
184        let owner = self.events.interaction_owner(&self.config.id);
185        self.events.present_interaction(owner.clone());
186        let dragged = self.events.interaction_is_active(&owner);
187        let hovered = prepaint.hitbox.is_hovered(window);
188        let color = if dragged || hovered {
189            self.config.active_color
190        } else {
191            self.config.idle_color
192        };
193        let line_width = if dragged { px(2.0) } else { px(1.0) };
194        let line = Bounds::new(
195            point(
196                bounds.origin.x + (bounds.size.width - line_width) / 2.0,
197                bounds.origin.y + px(4.0),
198            ),
199            size(line_width, (bounds.size.height - px(8.0)).max(px(1.0))),
200        );
201        window.paint_quad(fill(line, rgba(color.as_rgba_hex())));
202        window.set_cursor_style(CursorStyle::ResizeColumn, &prepaint.hitbox);
203        register_pointer_down(
204            prepaint,
205            self.config.clone(),
206            self.events.clone(),
207            self.measurements.clone(),
208            window,
209        );
210    }
211}
212
213fn register_pointer_down(
214    prepaint: &ResizePrepaint,
215    config: ResizeConfig,
216    events: PrimitiveContext,
217    measurements: ColumnMeasurementRegistry,
218    window: &mut Window,
219) {
220    let view = window.current_view();
221    let hitbox = prepaint.hitbox.clone();
222    window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
223        if phase != DispatchPhase::Bubble
224            || event.button != MouseButton::Left
225            || !hitbox.is_hovered(window)
226        {
227            return;
228        }
229        let Some(bounds) = events.element_bounds(&config.reference, cx) else {
230            return;
231        };
232        if event.click_count >= 2 {
233            let owner = events.interaction_owner(&config.id);
234            events.cancel_interaction(&owner, window, cx);
235            let measured = measurements
236                .maximum(config.signal.id())
237                .unwrap_or(bounds.width);
238            let width = clamp_width(measured, config.min, config.max);
239            let _ =
240                events.write_signal(&config.signal, SignalValue::OptionalFloat(Some(width)), cx);
241            let payload = resize_payload(&config.column_key, width);
242            events.propose("resize", payload, window, cx);
243            if events.observes("resize") {
244                schedule_preview_clear(&events, &config.signal, window, cx);
245            }
246            cx.stop_propagation();
247            cx.notify(view);
248            return;
249        }
250        let width = clamp_width(bounds.width, config.min, config.max);
251        let previous_override = events.read_signal(&config.signal, cx).ok();
252        let update_config = config.clone();
253        let update_events = events.clone();
254        let update = move |gesture: crate::interaction::GestureUpdate,
255                           _: &mut Window,
256                           cx: &mut App| {
257            if gesture.moved() {
258                let delta =
259                    horizontal_delta(gesture.start(), gesture.current(), update_config.direction);
260                let value = clamp_width(width + delta, update_config.min, update_config.max);
261                let _ = update_events.write_signal(
262                    &update_config.signal,
263                    SignalValue::OptionalFloat(Some(value)),
264                    cx,
265                );
266            }
267            crate::interaction::InteractionFlow::Continue
268        };
269        let finish_config = config.clone();
270        let finish_events = events.clone();
271        let finish =
272            move |gesture: crate::interaction::GestureUpdate, window: &mut Window, cx: &mut App| {
273                if !gesture.moved() {
274                    return;
275                }
276                let delta =
277                    horizontal_delta(gesture.start(), gesture.current(), finish_config.direction);
278                let value = clamp_width(width + delta, finish_config.min, finish_config.max);
279                let _ = finish_events.write_signal(
280                    &finish_config.signal,
281                    SignalValue::OptionalFloat(Some(value)),
282                    cx,
283                );
284                finish_events.propose(
285                    "resize",
286                    resize_payload(&finish_config.column_key, value),
287                    window,
288                    cx,
289                );
290                if finish_events.observes("resize") {
291                    schedule_preview_clear(&finish_events, &finish_config.signal, window, cx);
292                }
293            };
294        let cancel_signal = config.signal.clone();
295        let cancel_events = events.clone();
296        let cancel = move |_: &mut Window, cx: &mut App| {
297            let value = previous_override
298                .clone()
299                .unwrap_or(SignalValue::OptionalFloat(None));
300            let _ = cancel_events.write_signal(&cancel_signal, value, cx);
301        };
302        let owner = events.interaction_owner(&config.id);
303        events.begin_interaction(
304            crate::interaction::NativeGesture::new(
305                owner,
306                event.position,
307                view,
308                update,
309                finish,
310                cancel,
311            ),
312            window,
313            cx,
314        );
315        cx.stop_propagation();
316    });
317}
318
319fn schedule_preview_clear(
320    events: &PrimitiveContext,
321    signal: &crate::NativeSignal,
322    window: &mut Window,
323    cx: &mut App,
324) {
325    let events = events.clone();
326    let signal = signal.clone();
327    window.defer(cx, move |_, cx| {
328        let _ = events.write_signal(&signal, SignalValue::OptionalFloat(None), cx);
329    });
330}
331
332fn horizontal_delta(start: Point<Pixels>, current: Point<Pixels>, direction: TextDirection) -> f64 {
333    let delta = f64::from(current.x - start.x);
334    match direction {
335        TextDirection::LeftToRight => delta,
336        TextDirection::RightToLeft => -delta,
337    }
338}
339
340fn clamp_width(width: f64, min: f64, max: f64) -> f64 {
341    width.clamp(min, max)
342}
343
344fn resize_payload(column_key: &str, width: f64) -> UiValue {
345    UiValue::Map(BTreeMap::from([
346        ("key".to_owned(), UiValue::String(column_key.to_owned())),
347        (
348            "width".to_owned(),
349            UiValue::Map(BTreeMap::from([
350                ("kind".to_owned(), UiValue::String("fixed".to_owned())),
351                ("value".to_owned(), UiValue::Float(width)),
352            ])),
353        ),
354    ]))
355}
356
357#[derive(Clone)]
358struct IntrinsicMeasureConfig {
359    text: String,
360    group: SignalId,
361    horizontal_padding: Length,
362    extra_width: f64,
363}
364
365struct IntrinsicTextMeasureElement {
366    instance: u64,
367    config: IntrinsicMeasureConfig,
368    measurements: ColumnMeasurementRegistry,
369}
370
371impl IntoElement for IntrinsicTextMeasureElement {
372    type Element = Self;
373
374    fn into_element(self) -> Self::Element {
375        self
376    }
377}
378
379impl Element for IntrinsicTextMeasureElement {
380    type RequestLayoutState = ();
381    type PrepaintState = ();
382
383    fn id(&self) -> Option<ElementId> {
384        None
385    }
386
387    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
388        None
389    }
390
391    fn request_layout(
392        &mut self,
393        _: Option<&GlobalElementId>,
394        _: Option<&InspectorElementId>,
395        window: &mut Window,
396        cx: &mut App,
397    ) -> (LayoutId, Self::RequestLayoutState) {
398        let style = Style {
399            size: size(px(0.0).into(), px(0.0).into()),
400            ..Style::default()
401        };
402        (window.request_layout(style, None, cx), ())
403    }
404
405    fn prepaint(
406        &mut self,
407        _: Option<&GlobalElementId>,
408        _: Option<&InspectorElementId>,
409        _: Bounds<Pixels>,
410        (): &mut Self::RequestLayoutState,
411        window: &mut Window,
412        _: &mut App,
413    ) -> Self::PrepaintState {
414        let text_style = window.text_style();
415        let font_size = text_style.font_size.to_pixels(window.rem_size());
416        let runs = [text_style.to_run(self.config.text.len())];
417        if let Ok(lines) = window.text_system().shape_text(
418            SharedString::from(self.config.text.clone()),
419            font_size,
420            &runs,
421            None,
422            Some(1),
423        ) {
424            let text_width = lines
425                .iter()
426                .map(|line| f64::from(line.width()))
427                .reduce(f64::max)
428                .unwrap_or(0.0);
429            let padding =
430                absolute_length_pixels(self.config.horizontal_padding, window).unwrap_or_default();
431            let width = (text_width + padding * 2.0 + self.config.extra_width).ceil();
432            if width.is_finite() {
433                self.measurements
434                    .report(&self.config.group, self.instance, width);
435            }
436        }
437    }
438
439    fn paint(
440        &mut self,
441        _: Option<&GlobalElementId>,
442        _: Option<&InspectorElementId>,
443        _: Bounds<Pixels>,
444        (): &mut Self::RequestLayoutState,
445        (): &mut Self::PrepaintState,
446        _: &mut Window,
447        _: &mut App,
448    ) {
449    }
450}
451
452fn absolute_length_pixels(value: Length, window: &Window) -> Option<f64> {
453    match value {
454        Length::Pixels(value) => Some(value),
455        Length::Rems(value) => Some(value * f64::from(window.rem_size())),
456        Length::Relative(_) | Length::ThemeSpacing(_) | Length::ThemeRadius(_) => None,
457    }
458}
459
460#[derive(Default)]
461pub(crate) struct IntrinsicTextMeasurePrimitiveHandler {
462    measurements: ColumnMeasurementRegistry,
463    owners: BTreeMap<PrimitiveInstanceId, SignalId>,
464}
465
466impl IntrinsicTextMeasurePrimitiveHandler {
467    pub(crate) fn new(measurements: ColumnMeasurementRegistry) -> Self {
468        Self {
469            measurements,
470            owners: BTreeMap::new(),
471        }
472    }
473}
474
475impl PrimitiveHandler for IntrinsicTextMeasurePrimitiveHandler {
476    fn render(
477        &mut self,
478        instance: &PrimitiveInstance,
479        _: &PrimitiveContext,
480        theme: &PrimitiveTheme,
481        _: &mut Window,
482        _: &mut App,
483    ) -> Result<AnyElement, String> {
484        let id = instance
485            .id
486            .clone()
487            .ok_or_else(|| "intrinsic text measurement requires a keyed instance".to_owned())?;
488        let config = parse_measure_config(&instance.node.props, theme)?;
489        if let Some(previous) = self.owners.insert(id.clone(), config.group.clone())
490            && previous != config.group
491        {
492            self.measurements.remove(&previous, id.node().get());
493        }
494        Ok(IntrinsicTextMeasureElement {
495            instance: id.node().get(),
496            config,
497            measurements: self.measurements.clone(),
498        }
499        .into_any_element())
500    }
501
502    fn unmount(&mut self, instance: &PrimitiveInstanceId) {
503        if let Some(group) = self.owners.remove(instance) {
504            self.measurements.remove(&group, instance.node().get());
505        }
506    }
507}
508
509fn parse_measure_config(
510    props: &PrimitiveProps,
511    theme: &PrimitiveTheme,
512) -> Result<IntrinsicMeasureConfig, String> {
513    let text = props
514        .string("text")
515        .map(ToOwned::to_owned)
516        .ok_or_else(|| "intrinsic text measurement requires text".to_owned())?;
517    let group = props
518        .signal("group")
519        .ok_or_else(|| "intrinsic text measurement requires group".to_owned())?;
520    if group.id().kind() != SignalKind::OptionalFloat {
521        return Err("intrinsic text measurement group must be an optional-float signal".to_owned());
522    }
523    let horizontal_padding = match props.get("horizontal_padding") {
524        Some(PrimitiveValue::Length(value)) => theme
525            .resolve_length(*value)
526            .ok_or_else(|| "intrinsic text measurement padding did not resolve".to_owned())?,
527        None | Some(PrimitiveValue::Data(UiValue::Null)) => Length::Pixels(0.0),
528        Some(_) => return Err("intrinsic text measurement padding must be Length".to_owned()),
529    };
530    if !matches!(horizontal_padding, Length::Pixels(_) | Length::Rems(_)) {
531        return Err("intrinsic text measurement padding must resolve to pixels or rems".to_owned());
532    }
533    let extra_width = props.number("extra_width").unwrap_or(0.0);
534    if !extra_width.is_finite() || !(0.0..=MAX_COLUMN_WIDTH).contains(&extra_width) {
535        return Err(
536            "intrinsic text measurement extra width must be between 0 and 16384".to_owned(),
537        );
538    }
539    Ok(IntrinsicMeasureConfig {
540        text,
541        group: group.id().clone(),
542        horizontal_padding,
543        extra_width,
544    })
545}
546
547#[derive(Default)]
548pub struct ColumnResizePrimitiveHandler {
549    measurements: ColumnMeasurementRegistry,
550}
551
552impl ColumnResizePrimitiveHandler {
553    pub(crate) fn new(measurements: ColumnMeasurementRegistry) -> Self {
554        Self { measurements }
555    }
556}
557
558impl PrimitiveHandler for ColumnResizePrimitiveHandler {
559    fn render(
560        &mut self,
561        instance: &PrimitiveInstance,
562        events: &PrimitiveContext,
563        theme: &PrimitiveTheme,
564        _: &mut Window,
565        _: &mut App,
566    ) -> Result<AnyElement, String> {
567        let config = parse_config(&instance.node.props, theme)?;
568        Ok(ColumnResizeHandle {
569            config,
570            events: events.clone(),
571            measurements: self.measurements.clone(),
572        }
573        .into_any_element())
574    }
575}
576
577fn parse_config(props: &PrimitiveProps, theme: &PrimitiveTheme) -> Result<ResizeConfig, String> {
578    let column_key = props
579        .string("column_key")
580        .map(ToOwned::to_owned)
581        .ok_or_else(|| "column resize handle requires column_key".to_owned())?;
582    let source_kind = props
583        .string("source_kind")
584        .map(ToOwned::to_owned)
585        .ok_or_else(|| "column resize handle requires source_kind".to_owned())?;
586    let source_value = props
587        .number("source_value")
588        .ok_or_else(|| "column resize handle requires source_value".to_owned())?;
589    let min = props.number("min_width").unwrap_or(DEFAULT_MIN_WIDTH);
590    let max = props.number("max_width").unwrap_or(MAX_COLUMN_WIDTH);
591    if !source_value.is_finite()
592        || !min.is_finite()
593        || !max.is_finite()
594        || min <= 0.0
595        || max < min
596        || max > MAX_COLUMN_WIDTH
597    {
598        return Err("column resize widths must be finite and satisfy 0 < min <= max".to_owned());
599    }
600    let signal = props
601        .signal("signal")
602        .cloned()
603        .ok_or_else(|| "column resize handle requires signal".to_owned())?;
604    let reference = props
605        .element_ref("column_ref")
606        .cloned()
607        .ok_or_else(|| "column resize handle requires column_ref".to_owned())?;
608    Ok(ResizeConfig {
609        id: format!(
610            "gpui-rhai-column-resize:{}:{}",
611            signal.id().component(),
612            signal.id().key()
613        ),
614        column_key: column_key.clone(),
615        signal,
616        reference,
617        source: SourceWidth {
618            column_key,
619            kind: source_kind,
620            value: source_value,
621            min,
622            max,
623        },
624        min,
625        max,
626        direction: theme.direction(),
627        idle_color: theme
628            .color("border")
629            .unwrap_or(Rgba8::from_rgba_hex(0x5555_55ff)),
630        active_color: theme
631            .color("accent")
632            .unwrap_or(Rgba8::from_rgba_hex(0x3b82_f6ff)),
633    })
634}
635
636const fn resize_bound_schema() -> ValueSchema {
637    ValueSchema::Number {
638        min: None,
639        max: Some(MAX_COLUMN_WIDTH),
640        exclusive_min: Some(0.0),
641        exclusive_max: None,
642    }
643}
644
645/// Build the internal native Table column-resize primitive schema.
646///
647/// # Panics
648///
649/// Panics only if the static built-in primitive ID becomes invalid.
650#[must_use]
651pub fn column_resize_primitive_descriptor() -> PrimitiveDescriptor {
652    PrimitiveDescriptor {
653        id: PrimitiveId::parse("gpui_rhai.column_resize").expect("static primitive ID"),
654        export: "ColumnResizePrimitive".to_owned(),
655        props: BTreeMap::from([
656            (
657                "column_key".to_owned(),
658                ObjectField::required(ValueSchema::string()),
659            ),
660            (
661                "source_kind".to_owned(),
662                ObjectField::required(ValueSchema::String {
663                    allowed: vec!["fixed".to_owned(), "percent".to_owned(), "flex".to_owned()],
664                }),
665            ),
666            (
667                "source_value".to_owned(),
668                ObjectField::required(ValueSchema::positive_number()),
669            ),
670            (
671                "min_width".to_owned(),
672                ObjectField::optional(resize_bound_schema()),
673            ),
674            (
675                "max_width".to_owned(),
676                ObjectField::optional(resize_bound_schema()),
677            ),
678            (
679                "signal".to_owned(),
680                ObjectField::required(ValueSchema::Signal),
681            ),
682            (
683                "column_ref".to_owned(),
684                ObjectField::required(ValueSchema::Ref),
685            ),
686            (
687                "on_resize".to_owned(),
688                ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
689            ),
690        ]),
691        events: BTreeMap::from([(
692            "resize".to_owned(),
693            EventSchema {
694                payload: ValueSchema::object(BTreeMap::from([
695                    (
696                        "key".to_owned(),
697                        ObjectField::required(ValueSchema::string()),
698                    ),
699                    (
700                        "width".to_owned(),
701                        ObjectField::required(ValueSchema::object(BTreeMap::from([
702                            (
703                                "kind".to_owned(),
704                                ObjectField::required(ValueSchema::String {
705                                    allowed: vec!["fixed".to_owned()],
706                                }),
707                            ),
708                            (
709                                "value".to_owned(),
710                                ObjectField::required(ValueSchema::positive_number()),
711                            ),
712                        ]))),
713                    ),
714                ])),
715            },
716        )]),
717        state: ComponentStateSchema::default(),
718        lifecycle: false,
719        effect: None,
720    }
721}
722
723/// Build the internal intrinsic-text measurement primitive used by Table
724/// auto-fit. The primitive is zero-size and reports only within Rust.
725///
726/// # Panics
727///
728/// Panics only if the static built-in primitive ID becomes invalid.
729#[must_use]
730pub(crate) fn intrinsic_text_measure_primitive_descriptor() -> PrimitiveDescriptor {
731    PrimitiveDescriptor {
732        id: PrimitiveId::parse("gpui_rhai.intrinsic_text_measure").expect("static primitive ID"),
733        export: "IntrinsicTextMeasurePrimitive".to_owned(),
734        props: BTreeMap::from([
735            (
736                "text".to_owned(),
737                ObjectField::required(ValueSchema::string()),
738            ),
739            (
740                "group".to_owned(),
741                ObjectField::required(ValueSchema::Signal),
742            ),
743            (
744                "horizontal_padding".to_owned(),
745                ObjectField::optional(ValueSchema::Length),
746            ),
747            (
748                "extra_width".to_owned(),
749                ObjectField::optional(ValueSchema::Number {
750                    min: Some(0.0),
751                    max: Some(MAX_COLUMN_WIDTH),
752                    exclusive_min: None,
753                    exclusive_max: None,
754                }),
755            ),
756        ]),
757        events: BTreeMap::new(),
758        state: ComponentStateSchema::default(),
759        lifecycle: true,
760        effect: None,
761    }
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767
768    fn measurement_group() -> SignalId {
769        SignalId::new(
770            crate::ComponentInstancePath::root("Table", "users"),
771            "column-width-0",
772            SignalKind::OptionalFloat,
773        )
774        .unwrap()
775    }
776
777    #[test]
778    fn width_delta_and_clamping_follow_text_direction() {
779        let start = point(px(100.0), px(0.0));
780        let right = point(px(132.0), px(0.0));
781        assert!(
782            (horizontal_delta(start, right, TextDirection::LeftToRight) - 32.0).abs()
783                < f64::EPSILON
784        );
785        assert!(
786            (horizontal_delta(start, right, TextDirection::RightToLeft) + 32.0).abs()
787                < f64::EPSILON
788        );
789        assert!((clamp_width(10.0, 48.0, 200.0) - 48.0).abs() < f64::EPSILON);
790        assert!((clamp_width(240.0, 48.0, 200.0) - 200.0).abs() < f64::EPSILON);
791    }
792
793    #[test]
794    fn resize_event_uses_a_fixed_width_descriptor() {
795        assert_eq!(
796            resize_payload("name", 144.0),
797            UiValue::Map(BTreeMap::from([
798                ("key".to_owned(), UiValue::String("name".to_owned())),
799                (
800                    "width".to_owned(),
801                    UiValue::Map(BTreeMap::from([
802                        ("kind".to_owned(), UiValue::String("fixed".to_owned())),
803                        ("value".to_owned(), UiValue::Float(144.0)),
804                    ])),
805                ),
806            ]))
807        );
808    }
809
810    #[test]
811    fn realized_measurements_replace_and_unmount_without_retaining_stale_maxima() {
812        let registry = ColumnMeasurementRegistry::default();
813        let group = measurement_group();
814        registry.report(&group, 1, 80.0);
815        registry.report(&group, 2, 144.0);
816        assert_eq!(registry.maximum(&group), Some(144.0));
817
818        registry.report(&group, 2, 96.0);
819        assert_eq!(registry.maximum(&group), Some(96.0));
820        registry.remove(&group, 2);
821        assert_eq!(registry.maximum(&group), Some(80.0));
822        registry.remove(&group, 1);
823        assert_eq!(registry.maximum(&group), None);
824    }
825
826    #[test]
827    fn intrinsic_measurement_is_a_keyed_lifecycle_primitive() {
828        let descriptor = intrinsic_text_measure_primitive_descriptor();
829        assert!(descriptor.lifecycle);
830        assert_eq!(descriptor.id.as_str(), "gpui_rhai.intrinsic_text_measure");
831    }
832}