1use 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, Style, Window, fill, point, px, relative, rgba, size,
11};
12
13use crate::{
14 ComponentStateSchema, EventSchema, ObjectField, PrimitiveContext, PrimitiveDescriptor,
15 PrimitiveHandler, PrimitiveId, PrimitiveInstance, PrimitiveProps, PrimitiveTheme, Rgba8,
16 SignalKind, SignalValue, TextDirection, UiValue, ValueSchema,
17};
18
19const MAX_PANEL_SIZE: f64 = 16_384.0;
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22enum SplitOrientation {
23 Horizontal,
24 Vertical,
25}
26
27#[derive(Clone)]
28struct SplitResizeConfig {
29 id: String,
30 orientation: SplitOrientation,
31 source_ratio: f64,
32 min_start: f64,
33 min_end: f64,
34 max_start: f64,
35 disabled: bool,
36 signal: crate::NativeSignal,
37 group_ref: crate::ElementRef,
38 start_ref: crate::ElementRef,
39 direction: TextDirection,
40 idle_color: Rgba8,
41 active_color: Rgba8,
42}
43
44#[derive(Clone)]
45struct SplitResizeState(Rc<RefCell<SplitResizeStateInner>>);
46
47#[derive(Clone)]
48struct SplitResizeStateInner {
49 source_ratio: f64,
50 signal: crate::SignalId,
51 constraint_preview: Option<f64>,
52}
53
54impl SplitResizeState {
55 fn new(config: &SplitResizeConfig) -> Self {
56 Self(Rc::new(RefCell::new(SplitResizeStateInner {
57 source_ratio: config.source_ratio,
58 signal: config.signal.id().clone(),
59 constraint_preview: None,
60 })))
61 }
62}
63
64struct SplitResizePrepaint {
65 hitbox: Hitbox,
66}
67
68struct SplitResizeHandle {
69 config: SplitResizeConfig,
70 events: PrimitiveContext,
71}
72
73impl IntoElement for SplitResizeHandle {
74 type Element = Self;
75
76 fn into_element(self) -> Self::Element {
77 self
78 }
79}
80
81impl Element for SplitResizeHandle {
82 type RequestLayoutState = ();
83 type PrepaintState = SplitResizePrepaint;
84
85 fn id(&self) -> Option<ElementId> {
86 Some(ElementId::Name(self.config.id.clone().into()))
87 }
88
89 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
90 None
91 }
92
93 fn request_layout(
94 &mut self,
95 _: Option<&GlobalElementId>,
96 _: Option<&InspectorElementId>,
97 window: &mut Window,
98 cx: &mut App,
99 ) -> (LayoutId, Self::RequestLayoutState) {
100 (
101 window.request_layout(
102 Style {
103 size: size(relative(1.0).into(), relative(1.0).into()),
104 ..Style::default()
105 },
106 None,
107 cx,
108 ),
109 (),
110 )
111 }
112
113 fn prepaint(
114 &mut self,
115 _: Option<&GlobalElementId>,
116 _: Option<&InspectorElementId>,
117 bounds: Bounds<Pixels>,
118 (): &mut Self::RequestLayoutState,
119 window: &mut Window,
120 cx: &mut App,
121 ) -> Self::PrepaintState {
122 let state = window
123 .use_state(cx, |_, _| SplitResizeState::new(&self.config))
124 .read(cx)
125 .clone();
126 let reset = {
127 let mut inner = state.0.borrow_mut();
128 let changed = (inner.source_ratio - self.config.source_ratio).abs() > f64::EPSILON
129 || inner.signal != *self.config.signal.id();
130 if changed {
131 inner.source_ratio = self.config.source_ratio;
132 inner.signal = self.config.signal.id().clone();
133 inner.constraint_preview = None;
134 }
135 changed
136 };
137 let owner = self.events.interaction_owner(&self.config.id);
138 if reset {
139 self.events.cancel_interaction(&owner, window, cx);
140 }
141 let dragging = self.events.interaction_is_active(&owner);
142 if !dragging {
143 let constrained = self
144 .events
145 .element_bounds(&self.config.group_ref, cx)
146 .and_then(|group| {
147 let group_size = axis_size(group, self.config.orientation);
148 let handle_size = match self.config.orientation {
149 SplitOrientation::Horizontal => f64::from(bounds.size.width),
150 SplitOrientation::Vertical => f64::from(bounds.size.height),
151 };
152 let requested = self.config.source_ratio * group_size;
153 let legal = clamp_start_size(requested, group_size, handle_size, &self.config);
154 ((legal - requested).abs() > 0.5).then_some(legal)
155 });
156 let changed = {
157 let mut inner = state.0.borrow_mut();
158 let changed = !same_optional_float(inner.constraint_preview, constrained);
159 inner.constraint_preview = constrained;
160 changed
161 };
162 if reset || changed {
163 write_preview(&self.events, &self.config.signal, constrained, window, cx);
164 }
165 }
166 SplitResizePrepaint {
167 hitbox: window.insert_hitbox(bounds, HitboxBehavior::BlockMouseExceptScroll),
168 }
169 }
170
171 fn paint(
172 &mut self,
173 _: Option<&GlobalElementId>,
174 _: Option<&InspectorElementId>,
175 bounds: Bounds<Pixels>,
176 (): &mut Self::RequestLayoutState,
177 prepaint: &mut Self::PrepaintState,
178 window: &mut Window,
179 _: &mut App,
180 ) {
181 let owner = self.events.interaction_owner(&self.config.id);
182 self.events.present_interaction(owner.clone());
183 let dragged = self.events.interaction_is_active(&owner);
184 let hovered = !self.config.disabled && prepaint.hitbox.is_hovered(window);
185 let color = if dragged || hovered {
186 self.config.active_color
187 } else {
188 self.config.idle_color
189 };
190 let thickness = if dragged { px(2.0) } else { px(1.0) };
191 let line = match self.config.orientation {
192 SplitOrientation::Horizontal => Bounds::new(
193 point(
194 bounds.origin.x + (bounds.size.width - thickness) / 2.0,
195 bounds.origin.y + px(4.0),
196 ),
197 size(thickness, (bounds.size.height - px(8.0)).max(px(1.0))),
198 ),
199 SplitOrientation::Vertical => Bounds::new(
200 point(
201 bounds.origin.x + px(4.0),
202 bounds.origin.y + (bounds.size.height - thickness) / 2.0,
203 ),
204 size((bounds.size.width - px(8.0)).max(px(1.0)), thickness),
205 ),
206 };
207 window.paint_quad(fill(line, rgba(color.as_rgba_hex())));
208 if !self.config.disabled {
209 window.set_cursor_style(
210 match self.config.orientation {
211 SplitOrientation::Horizontal => CursorStyle::ResizeColumn,
212 SplitOrientation::Vertical => CursorStyle::ResizeRow,
213 },
214 &prepaint.hitbox,
215 );
216 }
217 register_pointer_listeners(prepaint, bounds, &self.config, &self.events, window);
218 }
219}
220
221#[allow(clippy::too_many_lines)]
222fn register_pointer_listeners(
223 prepaint: &SplitResizePrepaint,
224 handle_bounds: Bounds<Pixels>,
225 config: &SplitResizeConfig,
226 events: &PrimitiveContext,
227 window: &mut Window,
228) {
229 let view = window.current_view();
230 let hitbox = prepaint.hitbox.clone();
231 let down_config = config.clone();
232 let down_events = events.clone();
233 window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
234 if phase != DispatchPhase::Bubble
235 || event.button != MouseButton::Left
236 || down_config.disabled
237 || !hitbox.is_hovered(window)
238 {
239 return;
240 }
241 let Some(group) = down_events.element_bounds(&down_config.group_ref, cx) else {
242 return;
243 };
244 let Some(start) = down_events.element_bounds(&down_config.start_ref, cx) else {
245 return;
246 };
247 let start_size = axis_size(start, down_config.orientation);
248 let group_size = axis_size(group, down_config.orientation);
249 let handle_size = match down_config.orientation {
250 SplitOrientation::Horizontal => f64::from(handle_bounds.size.width),
251 SplitOrientation::Vertical => f64::from(handle_bounds.size.height),
252 };
253 let update_config = down_config.clone();
254 let update_events = down_events.clone();
255 let update =
256 move |gesture: crate::interaction::GestureUpdate, window: &mut Window, cx: &mut App| {
257 if !gesture.moved() {
258 return crate::interaction::InteractionFlow::Continue;
259 }
260 let delta = axis_delta(
261 gesture.start(),
262 gesture.current(),
263 update_config.orientation,
264 update_config.direction,
265 );
266 let size =
267 clamp_start_size(start_size + delta, group_size, handle_size, &update_config);
268 write_preview(
269 &update_events,
270 &update_config.signal,
271 Some(size),
272 window,
273 cx,
274 );
275 crate::interaction::InteractionFlow::Continue
276 };
277 let finish_config = down_config.clone();
278 let finish_events = down_events.clone();
279 let finish =
280 move |gesture: crate::interaction::GestureUpdate, window: &mut Window, cx: &mut App| {
281 clear_preview(&finish_events, &finish_config.signal, window, cx);
282 if !gesture.moved() || group_size <= 0.0 {
283 return;
284 }
285 let delta = axis_delta(
286 gesture.start(),
287 gesture.current(),
288 finish_config.orientation,
289 finish_config.direction,
290 );
291 let size =
292 clamp_start_size(start_size + delta, group_size, handle_size, &finish_config);
293 finish_events.propose(
294 "resize",
295 UiValue::Float((size / group_size).clamp(0.0, 1.0)),
296 window,
297 cx,
298 );
299 };
300 let cancel_signal = down_config.signal.clone();
301 let cancel_events = down_events.clone();
302 let cancel = move |window: &mut Window, cx: &mut App| {
303 clear_preview(&cancel_events, &cancel_signal, window, cx);
304 };
305 let owner = down_events.interaction_owner(&down_config.id);
306 down_events.begin_interaction(
307 crate::interaction::NativeGesture::new(
308 owner,
309 event.position,
310 view,
311 update,
312 finish,
313 cancel,
314 ),
315 window,
316 cx,
317 );
318 cx.stop_propagation();
319 });
320}
321
322fn clear_preview(
323 events: &PrimitiveContext,
324 signal: &crate::NativeSignal,
325 window: &mut Window,
326 cx: &mut App,
327) {
328 write_preview(events, signal, None, window, cx);
329}
330
331fn write_preview(
332 events: &PrimitiveContext,
333 signal: &crate::NativeSignal,
334 value: Option<f64>,
335 window: &mut Window,
336 cx: &mut App,
337) {
338 let events = events.clone();
339 let signal = signal.clone();
340 window.defer(cx, move |_, cx| {
341 let _ = events.write_signal(&signal, SignalValue::OptionalFloat(value), cx);
342 });
343}
344
345fn same_optional_float(left: Option<f64>, right: Option<f64>) -> bool {
346 match (left, right) {
347 (Some(left), Some(right)) => (left - right).abs() <= 0.5,
348 (None, None) => true,
349 _ => false,
350 }
351}
352
353fn axis_size(bounds: crate::GeometryBounds, orientation: SplitOrientation) -> f64 {
354 match orientation {
355 SplitOrientation::Horizontal => bounds.width,
356 SplitOrientation::Vertical => bounds.height,
357 }
358}
359
360fn axis_delta(
361 start: Point<Pixels>,
362 current: Point<Pixels>,
363 orientation: SplitOrientation,
364 direction: TextDirection,
365) -> f64 {
366 match orientation {
367 SplitOrientation::Vertical => f64::from(current.y - start.y),
368 SplitOrientation::Horizontal => {
369 let delta = f64::from(current.x - start.x);
370 match direction {
371 TextDirection::LeftToRight => delta,
372 TextDirection::RightToLeft => -delta,
373 }
374 }
375 }
376}
377
378fn clamp_start_size(
379 proposed: f64,
380 group_size: f64,
381 handle_size: f64,
382 config: &SplitResizeConfig,
383) -> f64 {
384 clamp_start_size_values(
385 proposed,
386 group_size,
387 handle_size,
388 config.min_start,
389 config.min_end,
390 config.max_start,
391 )
392}
393
394fn clamp_start_size_values(
395 proposed: f64,
396 group_size: f64,
397 handle_size: f64,
398 min_start: f64,
399 min_end: f64,
400 max_start: f64,
401) -> f64 {
402 let available = (group_size - handle_size).max(0.0);
403 let max_from_end = (available - min_end).max(0.0);
404 let max = max_start.min(max_from_end);
405 let min = min_start.min(max);
406 proposed.clamp(min, max)
407}
408
409#[derive(Default)]
410pub struct SplitResizePrimitiveHandler;
411
412impl PrimitiveHandler for SplitResizePrimitiveHandler {
413 fn render(
414 &mut self,
415 instance: &PrimitiveInstance,
416 events: &PrimitiveContext,
417 theme: &PrimitiveTheme,
418 _: &mut Window,
419 _: &mut App,
420 ) -> Result<AnyElement, String> {
421 Ok(SplitResizeHandle {
422 config: parse_config(&instance.node.props, theme)?,
423 events: events.clone(),
424 }
425 .into_any_element())
426 }
427}
428
429fn parse_config(
430 props: &PrimitiveProps,
431 theme: &PrimitiveTheme,
432) -> Result<SplitResizeConfig, String> {
433 let orientation = match props.string("orientation") {
434 Some("horizontal") => SplitOrientation::Horizontal,
435 Some("vertical") => SplitOrientation::Vertical,
436 _ => return Err("split resize orientation must be horizontal or vertical".to_owned()),
437 };
438 let source_ratio = props
439 .number("source_ratio")
440 .ok_or_else(|| "split resize requires source_ratio".to_owned())?;
441 let min_start = props.number("min_start").unwrap_or(0.0);
442 let min_end = props.number("min_end").unwrap_or(0.0);
443 let max_start = props.number("max_start").unwrap_or(MAX_PANEL_SIZE);
444 let disabled = props.boolean("disabled").unwrap_or(false);
445 if !source_ratio.is_finite()
446 || !(0.0..=1.0).contains(&source_ratio)
447 || !min_start.is_finite()
448 || !min_end.is_finite()
449 || !max_start.is_finite()
450 || min_start < 0.0
451 || min_end < 0.0
452 || max_start < min_start
453 || max_start > MAX_PANEL_SIZE
454 {
455 return Err("split resize bounds and ratio are invalid".to_owned());
456 }
457 let signal = props
458 .signal("signal")
459 .cloned()
460 .ok_or_else(|| "split resize requires signal".to_owned())?;
461 if signal.id().kind() != SignalKind::OptionalFloat {
462 return Err("split resize signal must be optional_float".to_owned());
463 }
464 let group_ref = props
465 .element_ref("group_ref")
466 .cloned()
467 .ok_or_else(|| "split resize requires group_ref".to_owned())?;
468 let start_ref = props
469 .element_ref("start_ref")
470 .cloned()
471 .ok_or_else(|| "split resize requires start_ref".to_owned())?;
472 Ok(SplitResizeConfig {
473 id: format!(
474 "gpui-rhai-split-resize:{}:{}",
475 signal.id().component(),
476 signal.id().key()
477 ),
478 orientation,
479 source_ratio,
480 min_start,
481 min_end,
482 max_start,
483 disabled,
484 signal,
485 group_ref,
486 start_ref,
487 direction: theme.direction(),
488 idle_color: theme
489 .color("border")
490 .unwrap_or(Rgba8::from_rgba_hex(0x5555_55ff)),
491 active_color: theme
492 .color("accent")
493 .unwrap_or(Rgba8::from_rgba_hex(0x3b82_f6ff)),
494 })
495}
496
497fn panel_bound_schema() -> ValueSchema {
498 ValueSchema::Number {
499 min: Some(0.0),
500 max: Some(MAX_PANEL_SIZE),
501 exclusive_min: None,
502 exclusive_max: None,
503 }
504}
505
506#[must_use]
512pub fn split_resize_primitive_descriptor() -> PrimitiveDescriptor {
513 PrimitiveDescriptor {
514 id: PrimitiveId::parse("gpui_rhai.split_resize").expect("static primitive ID"),
515 export: "SplitResizePrimitive".to_owned(),
516 props: BTreeMap::from([
517 (
518 "orientation".to_owned(),
519 ObjectField::required(ValueSchema::String {
520 allowed: vec!["horizontal".to_owned(), "vertical".to_owned()],
521 }),
522 ),
523 (
524 "source_ratio".to_owned(),
525 ObjectField::required(ValueSchema::Number {
526 min: Some(0.0),
527 max: Some(1.0),
528 exclusive_min: None,
529 exclusive_max: None,
530 }),
531 ),
532 (
533 "min_start".to_owned(),
534 ObjectField::optional(panel_bound_schema()),
535 ),
536 (
537 "min_end".to_owned(),
538 ObjectField::optional(panel_bound_schema()),
539 ),
540 (
541 "max_start".to_owned(),
542 ObjectField::optional(panel_bound_schema()),
543 ),
544 (
545 "disabled".to_owned(),
546 ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
547 ),
548 (
549 "signal".to_owned(),
550 ObjectField::required(ValueSchema::Signal),
551 ),
552 (
553 "group_ref".to_owned(),
554 ObjectField::required(ValueSchema::Ref),
555 ),
556 (
557 "start_ref".to_owned(),
558 ObjectField::required(ValueSchema::Ref),
559 ),
560 (
561 "on_resize".to_owned(),
562 ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
563 ),
564 ]),
565 events: BTreeMap::from([(
566 "resize".to_owned(),
567 EventSchema {
568 payload: ValueSchema::Number {
569 min: Some(0.0),
570 max: Some(1.0),
571 exclusive_min: None,
572 exclusive_max: None,
573 },
574 },
575 )]),
576 state: ComponentStateSchema::default(),
577 lifecycle: false,
578 effect: None,
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 #[test]
587 fn split_delta_and_constraints_are_directional_and_deterministic() {
588 let start = point(px(100.0), px(100.0));
589 let moved = point(px(132.0), px(118.0));
590 let ltr = axis_delta(
591 start,
592 moved,
593 SplitOrientation::Horizontal,
594 TextDirection::LeftToRight,
595 );
596 let rtl = axis_delta(
597 start,
598 moved,
599 SplitOrientation::Horizontal,
600 TextDirection::RightToLeft,
601 );
602 let vertical = axis_delta(
603 start,
604 moved,
605 SplitOrientation::Vertical,
606 TextDirection::RightToLeft,
607 );
608 assert!((ltr - 32.0).abs() < f64::EPSILON);
609 assert!((rtl + 32.0).abs() < f64::EPSILON);
610 assert!((vertical - 18.0).abs() < f64::EPSILON);
611 let maximum = clamp_start_size_values(450.0, 500.0, 8.0, 100.0, 120.0, 360.0);
612 let minimum = clamp_start_size_values(20.0, 500.0, 8.0, 100.0, 120.0, 360.0);
613 let overconstrained = clamp_start_size_values(100.0, 180.0, 8.0, 120.0, 120.0, 360.0);
614 assert!((maximum - 360.0).abs() < f64::EPSILON);
615 assert!((minimum - 100.0).abs() < f64::EPSILON);
616 assert!(
617 (overconstrained - 52.0).abs() < f64::EPSILON,
618 "when minima exceed available space, the end-panel minimum wins deterministically"
619 );
620 }
621}