1use std::collections::HashMap;
38use std::rc::Rc;
39
40use gpui::{
41 AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
42 Styled, Window, div, prelude::FluentBuilder, px,
43};
44use gpui_kit_semantics::{NodeSpec, Role, Semantic};
45
46use crate::foundation::{Disableable, Ident};
47use crate::layout::split::{HANDLE, SplitAxis, SplitPane, SplitSide};
48
49type ChangeHandler = Rc<dyn Fn(SplitChange, &mut Window, &mut App)>;
50
51#[derive(Debug, Clone, PartialEq)]
53pub struct SplitPaneSpec {
54 id: SharedString,
55 min_width: f32,
56 min_height: f32,
57 rail: f32,
58 collapsed: bool,
59}
60
61impl SplitPaneSpec {
62 pub fn new(id: impl Into<SharedString>) -> Self {
63 Self {
64 id: id.into(),
65 min_width: 0.0,
66 min_height: 0.0,
67 rail: 0.0,
68 collapsed: false,
69 }
70 }
71
72 pub fn min(self, min: f32) -> Self {
78 self.min_width(min).min_height(min)
79 }
80
81 pub fn min_width(mut self, min: f32) -> Self {
82 self.min_width = min.max(0.0);
83 self
84 }
85
86 pub fn min_height(mut self, min: f32) -> Self {
87 self.min_height = min.max(0.0);
88 self
89 }
90
91 pub fn rail(mut self, rail: f32) -> Self {
94 self.rail = rail.max(0.0);
95 self
96 }
97
98 pub fn collapsed(mut self, collapsed: bool) -> Self {
102 self.collapsed = collapsed;
103 self
104 }
105
106 pub fn id(&self) -> &SharedString {
107 &self.id
108 }
109
110 pub fn min_size(&self, axis: SplitAxis) -> f32 {
112 match axis {
113 SplitAxis::Horizontal => self.min_width,
114 SplitAxis::Vertical => self.min_height,
115 }
116 }
117
118 pub fn rail_size(&self) -> f32 {
119 self.rail
120 }
121
122 pub fn is_collapsed(&self) -> bool {
123 self.collapsed
124 }
125}
126
127#[derive(Debug, Clone, PartialEq)]
129pub enum SplitLayout {
130 Pane(SplitPaneSpec),
131 Branch {
132 id: SharedString,
133 axis: SplitAxis,
134 ratio: f32,
136 start: Box<SplitLayout>,
137 end: Box<SplitLayout>,
138 },
139}
140
141impl SplitLayout {
142 pub fn pane(id: impl Into<SharedString>) -> Self {
143 Self::Pane(SplitPaneSpec::new(id))
144 }
145
146 pub fn leaf(spec: SplitPaneSpec) -> Self {
147 Self::Pane(spec)
148 }
149
150 pub fn split(
151 id: impl Into<SharedString>,
152 axis: SplitAxis,
153 ratio: f32,
154 start: SplitLayout,
155 end: SplitLayout,
156 ) -> Self {
157 Self::Branch {
158 id: id.into(),
159 axis,
160 ratio: ratio.clamp(0.0, 1.0),
161 start: Box::new(start),
162 end: Box::new(end),
163 }
164 }
165
166 pub fn horizontal(
167 id: impl Into<SharedString>,
168 ratio: f32,
169 start: SplitLayout,
170 end: SplitLayout,
171 ) -> Self {
172 Self::split(id, SplitAxis::Horizontal, ratio, start, end)
173 }
174
175 pub fn vertical(
176 id: impl Into<SharedString>,
177 ratio: f32,
178 start: SplitLayout,
179 end: SplitLayout,
180 ) -> Self {
181 Self::split(id, SplitAxis::Vertical, ratio, start, end)
182 }
183
184 pub fn id(&self) -> &SharedString {
185 match self {
186 Self::Pane(spec) => &spec.id,
187 Self::Branch { id, .. } => id,
188 }
189 }
190
191 pub fn is_pane(&self) -> bool {
192 matches!(self, Self::Pane(_))
193 }
194
195 pub fn panes(&self) -> Vec<&SplitPaneSpec> {
197 let mut found = Vec::new();
198 self.walk(&mut |node| {
199 if let Self::Pane(spec) = node {
200 found.push(spec);
201 }
202 });
203 found
204 }
205
206 pub fn find(&self, id: &str) -> Option<&SplitLayout> {
208 if self.id().as_ref() == id {
209 return Some(self);
210 }
211 match self {
212 Self::Pane(_) => None,
213 Self::Branch { start, end, .. } => start.find(id).or_else(|| end.find(id)),
214 }
215 }
216
217 fn walk<'a>(&'a self, visit: &mut impl FnMut(&'a SplitLayout)) {
218 visit(self);
219 if let Self::Branch { start, end, .. } = self {
220 start.walk(visit);
221 end.walk(visit);
222 }
223 }
224
225 fn rail(&self) -> Option<f32> {
227 match self {
228 Self::Pane(spec) if spec.collapsed => Some(spec.rail),
229 _ => None,
230 }
231 }
232
233 fn divides(&self) -> bool {
238 match self {
239 Self::Pane(_) => false,
240 Self::Branch { start, end, .. } => start.rail().is_none() && end.rail().is_none(),
241 }
242 }
243
244 pub fn min_extent(&self, axis: SplitAxis) -> f32 {
246 match self {
247 Self::Pane(spec) => {
248 if spec.collapsed {
249 spec.rail
250 } else {
251 spec.min_size(axis)
252 }
253 }
254 Self::Branch {
255 axis: branch_axis,
256 start,
257 end,
258 ..
259 } => {
260 let (first, second) = (start.min_extent(axis), end.min_extent(axis));
261 if *branch_axis == axis {
262 first + second + if self.divides() { HANDLE } else { 0.0 }
263 } else {
264 first.max(second)
265 }
266 }
267 }
268 }
269
270 pub fn with_ratio(&self, split: &str, ratio: f32) -> Self {
272 self.mapped(&mut |node| match node {
273 Self::Branch {
274 id,
275 axis,
276 start,
277 end,
278 ..
279 } if id.as_ref() == split => Self::Branch {
280 id: id.clone(),
281 axis: *axis,
282 ratio: ratio.clamp(0.0, 1.0),
283 start: start.clone(),
284 end: end.clone(),
285 },
286 other => other.clone(),
287 })
288 }
289
290 pub fn with_collapsed(&self, pane: &str, collapsed: bool) -> Self {
292 self.mapped(&mut |node| match node {
293 Self::Pane(spec) if spec.id.as_ref() == pane => {
294 Self::Pane(spec.clone().collapsed(collapsed))
295 }
296 other => other.clone(),
297 })
298 }
299
300 pub fn applied(&self, change: &SplitChange) -> Self {
305 match change {
306 SplitChange::Ratio { split, ratio } => self.with_ratio(split, *ratio),
307 SplitChange::Collapsed { pane, .. } => self.with_collapsed(pane, true),
308 }
309 }
310
311 fn mapped(&self, map: &mut impl FnMut(&SplitLayout) -> SplitLayout) -> Self {
312 let replaced = map(self);
313 match replaced {
314 Self::Pane(spec) => Self::Pane(spec),
315 Self::Branch {
316 id,
317 axis,
318 ratio,
319 start,
320 end,
321 } => Self::Branch {
322 id,
323 axis,
324 ratio,
325 start: Box::new(start.mapped(map)),
326 end: Box::new(end.mapped(map)),
327 },
328 }
329 }
330
331 pub fn to_records(&self) -> Vec<SplitRecord> {
334 let mut records = Vec::new();
335 self.record_into(None, &mut records);
336 records
337 }
338
339 fn record_into(&self, parent: Option<SharedString>, records: &mut Vec<SplitRecord>) {
340 match self {
341 Self::Pane(spec) => records.push(SplitRecord {
342 id: spec.id.clone(),
343 parent,
344 kind: SplitKind::Pane,
345 ratio: 0.0,
346 min_width: spec.min_width,
347 min_height: spec.min_height,
348 rail: spec.rail,
349 collapsed: spec.collapsed,
350 }),
351 Self::Branch {
352 id,
353 axis,
354 ratio,
355 start,
356 end,
357 } => {
358 records.push(SplitRecord {
359 id: id.clone(),
360 parent,
361 kind: match axis {
362 SplitAxis::Horizontal => SplitKind::Horizontal,
363 SplitAxis::Vertical => SplitKind::Vertical,
364 },
365 ratio: *ratio,
366 min_width: 0.0,
367 min_height: 0.0,
368 rail: 0.0,
369 collapsed: false,
370 });
371 start.record_into(Some(id.clone()), records);
372 end.record_into(Some(id.clone()), records);
373 }
374 }
375 }
376
377 pub fn from_records(records: &[SplitRecord]) -> Result<Self, SplitRecordError> {
379 let mut children: HashMap<&str, Vec<&SplitRecord>> = HashMap::new();
380 let mut by_id: HashMap<&str, &SplitRecord> = HashMap::new();
381 let mut roots: Vec<&SplitRecord> = Vec::new();
382
383 for record in records {
384 if by_id.insert(record.id.as_ref(), record).is_some() {
385 return Err(SplitRecordError::DuplicateId(record.id.clone()));
386 }
387 match &record.parent {
388 Some(parent) => children.entry(parent.as_ref()).or_default().push(record),
389 None => roots.push(record),
390 }
391 }
392
393 for record in records {
394 if let Some(parent) = &record.parent
395 && !by_id.contains_key(parent.as_ref())
396 {
397 return Err(SplitRecordError::MissingParent {
398 id: record.id.clone(),
399 parent: parent.clone(),
400 });
401 }
402 }
403
404 let root = match roots.as_slice() {
405 [] => return Err(SplitRecordError::NoRoot),
406 [root] => *root,
407 _ => {
408 return Err(SplitRecordError::ManyRoots(
409 roots.iter().map(|record| record.id.clone()).collect(),
410 ));
411 }
412 };
413
414 let mut built = 0;
417 let layout = build(root, &children, &mut built)?;
418 if built != records.len() {
419 return Err(SplitRecordError::Unreachable);
420 }
421 Ok(layout)
422 }
423}
424
425fn build(
426 record: &SplitRecord,
427 children: &HashMap<&str, Vec<&SplitRecord>>,
428 built: &mut usize,
429) -> Result<SplitLayout, SplitRecordError> {
430 *built += 1;
431 let own = children
432 .get(record.id.as_ref())
433 .map(Vec::as_slice)
434 .unwrap_or_default();
435 match record.kind {
436 SplitKind::Pane => {
437 if !own.is_empty() {
438 return Err(SplitRecordError::PaneWithChildren(record.id.clone()));
439 }
440 Ok(SplitLayout::Pane(
441 SplitPaneSpec::new(record.id.clone())
442 .min_width(record.min_width)
443 .min_height(record.min_height)
444 .rail(record.rail)
445 .collapsed(record.collapsed),
446 ))
447 }
448 SplitKind::Horizontal | SplitKind::Vertical => {
449 let [start, end] = own else {
450 return Err(SplitRecordError::WrongChildCount {
451 id: record.id.clone(),
452 found: own.len(),
453 });
454 };
455 Ok(SplitLayout::split(
456 record.id.clone(),
457 match record.kind {
458 SplitKind::Horizontal => SplitAxis::Horizontal,
459 _ => SplitAxis::Vertical,
460 },
461 record.ratio,
462 build(start, children, built)?,
463 build(end, children, built)?,
464 ))
465 }
466 }
467}
468
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
471pub enum SplitKind {
472 Pane,
473 Horizontal,
474 Vertical,
475}
476
477impl SplitKind {
478 pub fn name(self) -> &'static str {
479 match self {
480 Self::Pane => "pane",
481 Self::Horizontal => "horizontal",
482 Self::Vertical => "vertical",
483 }
484 }
485}
486
487#[derive(Debug, Clone, PartialEq)]
493pub struct SplitRecord {
494 pub id: SharedString,
495 pub parent: Option<SharedString>,
496 pub kind: SplitKind,
497 pub ratio: f32,
498 pub min_width: f32,
499 pub min_height: f32,
500 pub rail: f32,
501 pub collapsed: bool,
502}
503
504#[derive(Debug, Clone, PartialEq)]
506pub enum SplitRecordError {
507 NoRoot,
508 ManyRoots(Vec<SharedString>),
509 DuplicateId(SharedString),
510 MissingParent {
511 id: SharedString,
512 parent: SharedString,
513 },
514 WrongChildCount {
516 id: SharedString,
517 found: usize,
518 },
519 PaneWithChildren(SharedString),
520 Unreachable,
522}
523
524impl std::fmt::Display for SplitRecordError {
525 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
526 match self {
527 Self::NoRoot => write!(formatter, "no record without a parent"),
528 Self::ManyRoots(ids) => {
529 let names: Vec<&str> = ids.iter().map(SharedString::as_ref).collect();
530 write!(formatter, "more than one root: {}", names.join(", "))
531 }
532 Self::DuplicateId(id) => write!(formatter, "`{id}` appears more than once"),
533 Self::MissingParent { id, parent } => {
534 write!(formatter, "`{id}` names a parent `{parent}` that is absent")
535 }
536 Self::WrongChildCount { id, found } => {
537 write!(formatter, "split `{id}` has {found} children, not 2")
538 }
539 Self::PaneWithChildren(id) => write!(formatter, "pane `{id}` has children"),
540 Self::Unreachable => write!(formatter, "records the root does not reach"),
541 }
542 }
543}
544
545impl std::error::Error for SplitRecordError {}
546
547#[derive(Debug, Clone, PartialEq)]
549pub enum SplitChange {
550 Ratio {
551 split: SharedString,
552 ratio: f32,
553 },
554 Collapsed {
555 split: SharedString,
556 side: SplitSide,
557 pane: SharedString,
558 },
559}
560
561#[derive(IntoElement)]
563pub struct SplitTree {
564 ident: Ident,
565 layout: SplitLayout,
566 panes: HashMap<SharedString, AnyElement>,
567 disabled: bool,
568 on_change: Option<ChangeHandler>,
569}
570
571impl std::fmt::Debug for SplitTree {
572 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
573 formatter
574 .debug_struct("SplitTree")
575 .field("ident", &self.ident)
576 .field("panes", &self.panes.len())
577 .field("disabled", &self.disabled)
578 .field("has_handler", &self.on_change.is_some())
579 .finish()
580 }
581}
582
583impl SplitTree {
584 pub fn new(ident: impl Into<Ident>) -> Self {
585 Self {
586 ident: ident.into(),
587 layout: SplitLayout::pane("pane"),
588 panes: HashMap::new(),
589 disabled: false,
590 on_change: None,
591 }
592 }
593
594 pub fn layout(mut self, layout: SplitLayout) -> Self {
595 self.layout = layout;
596 self
597 }
598
599 pub fn pane(mut self, id: impl Into<SharedString>, content: impl IntoElement) -> Self {
601 self.panes.insert(id.into(), content.into_any_element());
602 self
603 }
604
605 pub fn on_change(
606 mut self,
607 handler: impl Fn(SplitChange, &mut Window, &mut App) + 'static,
608 ) -> Self {
609 self.on_change = Some(Rc::new(handler));
610 self
611 }
612
613 fn node(
614 &self,
615 layout: &SplitLayout,
616 panes: &mut HashMap<SharedString, AnyElement>,
617 cx: &mut App,
618 ) -> AnyElement {
619 match layout {
620 SplitLayout::Pane(spec) => {
621 let ident = self.ident.child(spec.id.as_ref());
622 div()
623 .flex()
624 .flex_col()
625 .min_w(px(0.0))
626 .min_h(px(0.0))
627 .size_full()
628 .overflow_hidden()
629 .children(panes.remove(&spec.id))
630 .semantic_in(
631 cx,
632 NodeSpec::new(ident.semantic_id(), Role::Group)
633 .parent(self.ident.semantic_id())
634 .expanded(!spec.collapsed),
635 )
636 .into_any_element()
637 }
638 SplitLayout::Branch {
639 id,
640 axis,
641 ratio,
642 start,
643 end,
644 } => {
645 let horizontal = *axis == SplitAxis::Horizontal;
646 let first = self.node(start, panes, cx);
647 let second = self.node(end, panes, cx);
648
649 if let Some(rail) = start.rail().or_else(|| end.rail()) {
652 let start_rail = start.rail().is_some();
653 let fixed = |element: AnyElement| {
654 div()
655 .flex_none()
656 .when(horizontal, |frame| frame.w(px(rail)).h_full())
657 .when(!horizontal, |frame| frame.h(px(rail)).w_full())
658 .overflow_hidden()
659 .child(element)
660 };
661 let flexible = |element: AnyElement| {
662 div()
663 .flex_1()
664 .min_w(px(0.0))
665 .min_h(px(0.0))
666 .overflow_hidden()
667 .child(element)
668 };
669 let (first, second) = if start_rail {
670 (fixed(first), flexible(second))
671 } else {
672 (flexible(first), fixed(second))
673 };
674 return div()
675 .flex()
676 .when(horizontal, |frame| frame.flex_row())
677 .when(!horizontal, |frame| frame.flex_col())
678 .items_stretch()
679 .size_full()
680 .overflow_hidden()
681 .child(first)
682 .child(second)
683 .into_any_element();
684 }
685
686 let split_id = id.clone();
687 let collapsible = start.is_pane() || end.is_pane();
688 let start_pane = start.id().clone();
689 let end_pane = end.id().clone();
690 let start_is_pane = start.is_pane();
691 let end_is_pane = end.is_pane();
692
693 let mut split = SplitPane::new(self.ident.child(id.as_ref()))
694 .axis(*axis)
695 .ratio(*ratio)
696 .min_sizes(start.min_extent(*axis), end.min_extent(*axis))
697 .collapsible(collapsible)
698 .disabled(self.disabled)
699 .start(first)
700 .end(second);
701
702 if let Some(handler) = self.on_change.clone() {
703 let resized = split_id.clone();
704 let reported = Rc::clone(&handler);
705 split = split.on_resize(move |ratio, window, cx| {
706 reported(
707 SplitChange::Ratio {
708 split: resized.clone(),
709 ratio,
710 },
711 window,
712 cx,
713 );
714 });
715 split = split.on_collapse(move |side, window, cx| {
716 let pane = match side {
719 SplitSide::Start if start_is_pane => start_pane.clone(),
720 SplitSide::End if end_is_pane => end_pane.clone(),
721 _ => return,
722 };
723 handler(
724 SplitChange::Collapsed {
725 split: split_id.clone(),
726 side,
727 pane,
728 },
729 window,
730 cx,
731 );
732 });
733 }
734
735 split.into_any_element()
736 }
737 }
738 }
739}
740
741impl Disableable for SplitTree {
742 fn disabled(mut self, disabled: bool) -> Self {
744 self.disabled = disabled;
745 self
746 }
747}
748
749impl RenderOnce for SplitTree {
750 fn render(mut self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
751 let layout = self.layout.clone();
752 let mut panes = std::mem::take(&mut self.panes);
753 let body = self.node(&layout, &mut panes, cx);
754 div()
755 .id(self.ident.element_id())
756 .size_full()
757 .overflow_hidden()
758 .child(body)
759 .semantic_in(
760 cx,
761 NodeSpec::new(self.ident.semantic_id(), Role::Group)
762 .value(layout.panes().len().to_string()),
763 )
764 }
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770
771 fn workspace() -> SplitLayout {
772 SplitLayout::vertical(
773 "root",
774 0.75,
775 SplitLayout::horizontal(
776 "body",
777 0.25,
778 SplitLayout::leaf(SplitPaneSpec::new("files").min_width(180.0).rail(40.0)),
779 SplitLayout::leaf(SplitPaneSpec::new("editor").min_width(320.0)),
780 ),
781 SplitLayout::leaf(SplitPaneSpec::new("terminal").min_height(120.0)),
782 )
783 }
784
785 #[test]
786 fn a_tree_round_trips_through_plain_records() {
787 let layout = workspace();
788 let records = layout.to_records();
789 assert_eq!(records.len(), 5);
790 assert_eq!(records[0].parent, None);
791 assert_eq!(SplitLayout::from_records(&records), Ok(layout.clone()));
792 assert_eq!(
793 SplitLayout::from_records(&records)
794 .expect("the records are a tree")
795 .to_records(),
796 records,
797 "the conversion loses nothing in either direction"
798 );
799 }
800
801 #[test]
802 fn records_that_are_not_a_tree_say_why() {
803 assert_eq!(
804 SplitLayout::from_records(&[]),
805 Err(SplitRecordError::NoRoot)
806 );
807
808 let mut records = workspace().to_records();
809 records.retain(|record| record.id.as_ref() != "terminal");
810 assert_eq!(
811 SplitLayout::from_records(&records),
812 Err(SplitRecordError::WrongChildCount {
813 id: "root".into(),
814 found: 1
815 })
816 );
817
818 let mut orphan = workspace().to_records();
819 orphan[4].parent = Some("nowhere".into());
820 assert!(matches!(
821 SplitLayout::from_records(&orphan),
822 Err(SplitRecordError::MissingParent { .. })
823 ));
824 }
825
826 #[test]
827 fn a_minimum_is_the_sum_along_the_axis_and_the_larger_across_it() {
828 let layout = workspace();
829 let body = layout.find("body").expect("body is in the tree");
830 assert_eq!(
832 body.min_extent(SplitAxis::Horizontal),
833 180.0 + 320.0 + HANDLE
834 );
835 assert_eq!(body.min_extent(SplitAxis::Vertical), 0.0);
838 assert_eq!(layout.min_extent(SplitAxis::Vertical), 120.0 + HANDLE);
840 assert_eq!(
841 layout.min_extent(SplitAxis::Horizontal),
842 180.0 + 320.0 + HANDLE,
843 "the widest row decides the whole tree's width"
844 );
845 }
846
847 #[test]
848 fn a_collapsed_leaf_is_worth_its_rail_and_removes_the_divider() {
849 let collapsed = workspace().with_collapsed("files", true);
850 let body = collapsed.find("body").expect("body is in the tree");
851 assert_eq!(body.min_extent(SplitAxis::Horizontal), 40.0 + 320.0);
852 }
853
854 #[test]
855 fn applying_a_reported_change_produces_the_tree_the_host_would_store() {
856 let layout = workspace();
857 let moved = layout.applied(&SplitChange::Ratio {
858 split: "body".into(),
859 ratio: 0.4,
860 });
861 let SplitLayout::Branch { ratio, .. } = moved.find("body").expect("body is in the tree")
862 else {
863 panic!("body is a split");
864 };
865 assert_eq!(*ratio, 0.4);
866 assert_ne!(moved, layout, "the caller's tree is untouched");
867
868 let hidden = layout.applied(&SplitChange::Collapsed {
869 split: "body".into(),
870 side: SplitSide::Start,
871 pane: "files".into(),
872 });
873 assert!(hidden.panes()[0].is_collapsed());
874 }
875
876 #[test]
877 fn every_leaf_is_reachable_by_name() {
878 let layout = workspace();
879 let names: Vec<&str> = layout
880 .panes()
881 .iter()
882 .map(|spec| spec.id().as_ref())
883 .collect();
884 assert_eq!(names, vec!["files", "editor", "terminal"]);
885 assert!(layout.find("editor").is_some());
886 assert!(layout.find("nothing").is_none());
887 }
888}