1use std::ops::Range;
2
3use gpui::{
4 Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, px,
5};
6
7mod panel;
8mod resize_handle;
9pub use panel::*;
10#[doc(hidden)]
11pub use resize_handle::*;
12
13#[doc(hidden)]
14pub const PANEL_MIN_SIZE: Pixels = px(100.);
15
16pub fn h_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
18 ResizablePanelGroup::new(id).axis(Axis::Horizontal)
19}
20
21pub fn v_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
23 ResizablePanelGroup::new(id).axis(Axis::Vertical)
24}
25
26pub fn resizable_panel() -> ResizablePanel {
28 ResizablePanel::new()
29}
30
31#[derive(Debug, Clone)]
33pub struct ResizableState {
34 axis: Axis,
36 panels: Vec<ResizablePanelState>,
37 sizes: Vec<Pixels>,
38 resizing_panel_ix: Option<usize>,
39 bounds: Bounds<Pixels>,
40}
41
42impl Default for ResizableState {
43 fn default() -> Self {
44 Self {
45 axis: Axis::Horizontal,
46 panels: vec![],
47 sizes: vec![],
48 resizing_panel_ix: None,
49 bounds: Bounds::default(),
50 }
51 }
52}
53
54impl ResizableState {
55 pub fn sizes(&self) -> &Vec<Pixels> {
57 &self.sizes
58 }
59
60 pub fn resize_panel(
70 &mut self,
71 ix: usize,
72 size: Pixels,
73 window: &mut Window,
74 cx: &mut Context<Self>,
75 ) {
76 if ix >= self.sizes.len() {
77 return;
78 }
79 if ix + 1 < self.sizes.len() {
80 self.resize_panel_at_handle(ix, size, window, cx);
81 } else if ix > 0 {
82 let delta = self.sizes[ix] - size;
85 let prev = self.sizes[ix - 1];
86 self.resize_panel_at_handle(ix - 1, prev + delta, window, cx);
87 }
88 self.done_resizing(cx);
89 }
90
91 pub fn insert_panel(
96 &mut self,
97 size: Option<Pixels>,
98 ix: Option<usize>,
99 cx: &mut Context<Self>,
100 ) {
101 let panel_state = ResizablePanelState {
102 size,
103 ..Default::default()
104 };
105
106 let size = size.unwrap_or(PANEL_MIN_SIZE);
107
108 let container_size = self.container_size().max(px(1.));
111 let total_leftover_size = (container_size - size).max(px(1.));
112
113 for (i, panel) in self.panels.iter_mut().enumerate() {
114 let ratio = self.sizes[i] / container_size;
115 self.sizes[i] = total_leftover_size * ratio;
116 panel.size = Some(self.sizes[i]);
117 }
118
119 if let Some(ix) = ix {
120 self.panels.insert(ix, panel_state);
121 self.sizes.insert(ix, size);
122 } else {
123 self.panels.push(panel_state);
124 self.sizes.push(size);
125 };
126
127 cx.notify();
128 }
129
130 pub(crate) fn adopt_sizes(&mut self, sizes: &[Option<Pixels>], cx: &mut Context<Self>) {
138 let mut changed = false;
139 for (ix, size) in sizes.iter().enumerate() {
140 if let Some(panel) = self.panels.get_mut(ix) {
148 if panel.size != *size {
149 panel.size = *size;
150 changed = true;
151 }
152 }
153
154 let Some(size) = size else { continue };
158 if let Some(slot) = self.sizes.get_mut(ix) {
159 if *slot != *size {
160 *slot = *size;
161 changed = true;
162 }
163 }
164 }
165
166 if changed {
167 cx.notify();
168 }
169 }
170
171 pub(crate) fn sync_panels_count(
172 &mut self,
173 axis: Axis,
174 panels_count: usize,
175 cx: &mut Context<Self>,
176 ) {
177 let mut changed = self.axis != axis;
178 self.axis = axis;
179
180 if panels_count > self.panels.len() {
181 let diff = panels_count - self.panels.len();
182 self.panels
183 .extend(vec![ResizablePanelState::default(); diff]);
184 self.sizes.extend(vec![PANEL_MIN_SIZE; diff]);
185 changed = true;
186 }
187
188 if panels_count < self.panels.len() {
189 self.panels.truncate(panels_count);
190 self.sizes.truncate(panels_count);
191 changed = true;
192 }
193
194 if changed {
195 self.adjust_to_container_size(cx);
197 }
198 }
199
200 pub(crate) fn update_panel_size(
201 &mut self,
202 panel_ix: usize,
203 bounds: Bounds<Pixels>,
204 size_range: Range<Pixels>,
205 cx: &mut Context<Self>,
206 ) {
207 let size = bounds.size.along(self.axis);
208 if self.sizes[panel_ix].as_f32() == PANEL_MIN_SIZE.as_f32() {
212 self.sizes[panel_ix] = size;
213 self.panels[panel_ix].size = Some(size);
214 }
215 self.panels[panel_ix].bounds = bounds;
216 self.panels[panel_ix].size_range = size_range;
217 cx.notify();
218 }
219
220 pub fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
222 self.panels.remove(panel_ix);
223 self.sizes.remove(panel_ix);
224 if let Some(resizing_panel_ix) = self.resizing_panel_ix {
225 if resizing_panel_ix > panel_ix {
226 self.resizing_panel_ix = Some(resizing_panel_ix - 1);
227 }
228 }
229 self.adjust_to_container_size(cx);
230 }
231
232 pub fn reset_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
234 let old_size = self.sizes[panel_ix];
235
236 self.panels[panel_ix] = ResizablePanelState::default();
237 self.sizes[panel_ix] = old_size;
238 self.adjust_to_container_size(cx);
239 }
240
241 pub fn clear(&mut self) {
243 self.panels.clear();
244 self.sizes.clear();
245 }
246
247 #[inline]
248 pub fn container_size(&self) -> Pixels {
250 self.bounds.size.along(self.axis)
251 }
252
253 pub(crate) fn done_resizing(&mut self, cx: &mut Context<Self>) {
254 self.resizing_panel_ix = None;
255 cx.emit(ResizablePanelEvent::Resized);
256 }
257
258 fn panel_size_range(&self, ix: usize) -> Range<Pixels> {
259 let Some(panel) = self.panels.get(ix) else {
260 return PANEL_MIN_SIZE..Pixels::MAX;
261 };
262
263 panel.size_range.clone()
264 }
265
266 fn sync_real_panel_sizes(&mut self, _: &App) {
267 for (i, panel) in self.panels.iter().enumerate() {
268 self.sizes[i] = panel.bounds.size.along(self.axis);
269 }
270 }
271
272 fn resize_panel_at_handle(
279 &mut self,
280 ix: usize,
281 size: Pixels,
282 _: &mut Window,
283 cx: &mut Context<Self>,
284 ) {
285 let old_sizes = self.sizes.clone();
286
287 let mut ix = ix;
288 if ix >= old_sizes.len() - 1 {
290 return;
291 }
292 let container_size = self.container_size();
293 self.sync_real_panel_sizes(cx);
294
295 let move_changed = size - old_sizes[ix];
296 if move_changed == px(0.) {
297 return;
298 }
299
300 let size_range = self.panel_size_range(ix);
301 let new_size = size.clamp(size_range.start, size_range.end);
302 let is_expand = move_changed > px(0.);
303
304 let main_ix = ix;
305 let mut new_sizes = old_sizes.clone();
306
307 if is_expand {
308 let mut changed = new_size - old_sizes[ix];
309 new_sizes[ix] = new_size;
310
311 while changed > px(0.) && ix < old_sizes.len() - 1 {
312 ix += 1;
313 let size_range = self.panel_size_range(ix);
314 let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
315 let to_reduce = changed.min(available_size);
316 new_sizes[ix] -= to_reduce;
317 changed -= to_reduce;
318 }
319 } else {
320 let mut changed = new_size - size;
321 new_sizes[ix] = new_size;
322
323 while changed > px(0.) && ix > 0 {
324 ix -= 1;
325 let size_range = self.panel_size_range(ix);
326 let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
327 let to_reduce = changed.min(available_size);
328 changed -= to_reduce;
329 new_sizes[ix] -= to_reduce;
330 }
331
332 new_sizes[main_ix + 1] += old_sizes[main_ix] - size - changed;
333 }
334
335 let total_size: Pixels = new_sizes.iter().map(|s| s.as_f32()).sum::<f32>().into();
337 if total_size > container_size {
338 let overflow = total_size - container_size;
339 new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(size_range.start);
340 }
341
342 for (i, _) in old_sizes.iter().enumerate() {
343 let size = new_sizes[i];
344 self.panels[i].size = Some(size);
345 }
346 self.sizes = new_sizes;
347 cx.notify();
348 }
349
350 fn adjust_to_container_size(&mut self, cx: &mut Context<Self>) {
354 if self.container_size().is_zero() {
355 return;
356 }
357
358 if self.panels.iter().any(|panel| panel.size.is_none()) {
366 return;
367 }
368
369 let container_size = self.container_size();
370 let total = self.sizes.iter().map(|s| s.as_f32()).sum::<f32>();
371 if !total.is_finite() || total <= 0. {
372 return;
373 }
374 let total_size = px(total);
375
376 for i in 0..self.panels.len() {
377 let size = self.sizes[i];
378 let ratio = size / total_size;
379 let new_size = container_size * ratio;
380
381 self.sizes[i] = new_size;
382 self.panels[i].size = Some(new_size);
383 }
384 cx.notify();
385 }
386}
387
388impl EventEmitter<ResizablePanelEvent> for ResizableState {}
389
390#[derive(Debug, Clone, Default)]
391pub(crate) struct ResizablePanelState {
392 pub size: Option<Pixels>,
393 pub size_range: Range<Pixels>,
394 bounds: Bounds<Pixels>,
395}
396
397#[cfg(test)]
398mod tests {
399 use std::{cell::Cell, rc::Rc};
400
401 use gpui::{
402 AppContext as _, Context, InteractiveElement as _, IntoElement, Modifiers, MouseButton,
403 ParentElement as _, Pixels, Render, Styled as _, TestAppContext, VisualTestContext, Window,
404 div, point, px, size,
405 };
406
407 use super::{ResizableState, h_resizable, resizable_panel};
408
409 struct MixedSizingHarness {
410 width: Pixels,
411 }
412
413 impl Render for MixedSizingHarness {
414 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
415 div().w(self.width).h(px(100.)).child(
416 h_resizable("mixed-sizing")
417 .child(
418 resizable_panel()
419 .size(px(240.))
420 .child(div().size_full().debug_selector(|| "fixed-sidebar".into())),
421 )
422 .child(
423 resizable_panel().child(
424 div()
425 .size_full()
426 .debug_selector(|| "flexible-content".into()),
427 ),
428 ),
429 )
430 }
431 }
432
433 #[gpui::test]
434 fn mixed_sizing_is_stable_between_resize_and_followup_frame(cx: &mut TestAppContext) {
435 let (view, cx) = cx.add_window_view(|_, _| MixedSizingHarness { width: px(800.) });
436 cx.update(|window, cx| {
437 window.draw(cx).clear(cx);
438 window.draw(cx).clear(cx);
439 });
440 let before = cx.debug_bounds("fixed-sidebar").unwrap().size.width;
441
442 view.update(cx, |view, cx| {
443 view.width = px(1200.);
444 cx.notify();
445 });
446 cx.run_until_parked();
447 let settled_frame = cx.debug_bounds("fixed-sidebar").unwrap().size.width;
448 cx.update(|window, cx| window.draw(cx).clear(cx));
449 let followup_frame = cx.debug_bounds("fixed-sidebar").unwrap().size.width;
450
451 assert_ne!(settled_frame, before);
455 assert_eq!(followup_frame, settled_frame);
456 }
457
458 struct CallerStateHarness {
459 width: Pixels,
460 state: gpui::Entity<ResizableState>,
461 }
462
463 impl Render for CallerStateHarness {
464 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
465 div().w(self.width).h(px(100.)).child(
466 h_resizable("caller-state")
467 .with_state(&self.state)
468 .child(
469 resizable_panel()
470 .size(px(240.))
471 .child(div().size_full().debug_selector(|| "cs-sidebar".into())),
472 )
473 .child(resizable_panel().child(div().size_full())),
474 )
475 }
476 }
477
478 #[gpui::test]
482 fn caller_owned_state_settles_on_the_same_frame(cx: &mut TestAppContext) {
483 let state = cx.update(|cx| cx.new(|_| ResizableState::default()));
484 let (view, cx) = cx.add_window_view({
485 let state = state.clone();
486 move |_, _| CallerStateHarness {
487 width: px(800.),
488 state,
489 }
490 });
491 cx.update(|window, cx| {
492 window.draw(cx).clear(cx);
493 window.draw(cx).clear(cx);
494 });
495
496 view.update(cx, |view, cx| {
497 view.width = px(1200.);
498 cx.notify();
499 });
500 cx.run_until_parked();
501 let settled = cx.debug_bounds("cs-sidebar").unwrap().size.width;
502 cx.update(|window, cx| window.draw(cx).clear(cx));
503 let followup = cx.debug_bounds("cs-sidebar").unwrap().size.width;
504
505 assert_eq!(followup, settled, "settling frame must not be pending");
506 }
507
508 struct ResizableHarness {
509 state: gpui::Entity<ResizableState>,
510 resizes: Rc<Cell<usize>>,
511 }
512
513 impl Render for ResizableHarness {
514 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
515 div().w(px(400.)).h(px(100.)).child(
516 h_resizable("resizable")
517 .with_state(&self.state)
518 .on_resize({
519 let resizes = self.resizes.clone();
520 move |_, _, _| resizes.set(resizes.get() + 1)
521 })
522 .child(
523 resizable_panel()
524 .size(px(150.))
525 .child(div().size_full().debug_selector(|| "first-panel".into())),
526 )
527 .child(
528 resizable_panel()
529 .size(px(250.))
530 .child(div().size_full().debug_selector(|| "second-panel".into())),
531 ),
532 )
533 }
534 }
535
536 fn harness(
537 cx: &mut TestAppContext,
538 ) -> (
539 &mut VisualTestContext,
540 gpui::Entity<ResizableState>,
541 Rc<Cell<usize>>,
542 ) {
543 let state = cx.update(|cx| cx.new(|_| ResizableState::default()));
544 let resizes = Rc::new(Cell::new(0));
545 let (_, cx) = cx.add_window_view({
546 let state = state.clone();
547 let resizes = resizes.clone();
548 move |_, _| ResizableHarness { state, resizes }
549 });
550 cx.update(|window, cx| window.draw(cx).clear(cx));
551 cx.update(|window, cx| window.draw(cx).clear(cx));
552 (cx, state, resizes)
553 }
554
555 #[gpui::test]
556 fn dynamic_panel_lifecycle_is_owned_by_resizable_state(cx: &mut TestAppContext) {
557 let state = cx.update(|cx| cx.new(|_| ResizableState::default()));
558
559 cx.update(|cx| {
560 state.update(cx, |state, cx| {
561 state.bounds.size = size(px(400.), px(100.));
562 state.panels.push(Default::default());
563 state.sizes.push(px(400.));
564 state.insert_panel(Some(px(200.)), None, cx);
565 assert_eq!(state.sizes(), &vec![px(200.), px(200.)]);
566
567 state.reset_panel(0, cx);
568 assert_eq!(state.sizes(), &vec![px(200.), px(200.)]);
569
570 state.remove_panel(0, cx);
571 assert_eq!(state.sizes(), &vec![px(400.)]);
572
573 state.clear();
574 assert!(state.sizes().is_empty());
575 });
576 });
577 }
578
579 #[gpui::test]
580 fn group_measures_panels_and_programmatic_resize_uses_drag_rules(cx: &mut TestAppContext) {
581 let (cx, state, _) = harness(cx);
582 let first = cx.debug_bounds("first-panel").unwrap();
583 let second = cx.debug_bounds("second-panel").unwrap();
584 assert_eq!(first.size.width + second.size.width, px(400.));
585
586 cx.update(|window, cx| {
587 state.update(cx, |state, cx| {
588 state.resize_panel(0, px(220.), window, cx);
589 });
590 window.draw(cx).clear(cx);
591 });
592
593 state.read_with(cx, |state, _| {
594 assert_eq!(state.sizes(), &vec![px(220.), px(180.)]);
595 });
596 }
597
598 #[gpui::test]
599 fn dragging_the_handle_resizes_and_emits_once(cx: &mut TestAppContext) {
600 let (cx, state, resizes) = harness(cx);
601 let boundary = cx.debug_bounds("second-panel").unwrap().left();
602
603 cx.simulate_mouse_down(
604 point(boundary - px(2.), px(50.)),
605 MouseButton::Left,
606 Modifiers::default(),
607 );
608 cx.simulate_mouse_move(
609 point(boundary + px(10.), px(50.)),
610 Some(MouseButton::Left),
611 Modifiers::default(),
612 );
613 cx.simulate_mouse_move(
614 point(px(220.), px(50.)),
615 Some(MouseButton::Left),
616 Modifiers::default(),
617 );
618 cx.simulate_mouse_up(
619 point(px(220.), px(50.)),
620 MouseButton::Left,
621 Modifiers::default(),
622 );
623
624 state.read_with(cx, |state, _| {
625 assert_eq!(state.sizes(), &vec![px(220.), px(180.)]);
626 });
627 assert_eq!(resizes.get(), 1);
628 }
629
630 struct SizedGroupHarness;
631
632 impl Render for SizedGroupHarness {
633 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
634 div()
635 .w(px(400.))
636 .h(px(100.))
637 .child(
638 h_resizable("sized-resizable").size(px(40.)).child(
639 resizable_panel()
640 .child(div().size_full().debug_selector(|| "sized-panel".into())),
641 ),
642 )
643 }
644 }
645
646 #[gpui::test]
647 fn a_group_size_binds_the_cross_axis(cx: &mut TestAppContext) {
648 let (_, cx) = cx.add_window_view(|_, _| SizedGroupHarness);
649 cx.update(|window, cx| window.draw(cx).clear(cx));
650 cx.update(|window, cx| window.draw(cx).clear(cx));
651
652 let panel = cx.debug_bounds("sized-panel").unwrap();
653 assert_eq!(panel.size.width, px(400.));
654 assert_eq!(panel.size.height, px(40.));
655 }
656}