Skip to main content

egui_macview/layout/
split_view.rs

1//! Authentic macOS AppKit Hairline Split View (NSSplitView equivalent).
2//!
3//! Grounded in macOS HIG and W3C DTCG tokens via `MacTheme`.
4//! Fully data-driven: zero hardcoded colors or dimensions.
5
6use crate::theme::MacTheme;
7use egui::{CornerRadius, CursorIcon, Id, Pos2, Rect, Stroke, StrokeKind, Ui, UiBuilder, Vec2};
8
9/// Orientation of the split view divider.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum MacSplitOrientation {
12    /// Left and right panes divided by a vertical hairline (default).
13    #[default]
14    Horizontal,
15    /// Top and bottom panes divided by a horizontal hairline.
16    Vertical,
17}
18
19/// Authentic macOS AppKit split container (`NSSplitView` equivalent).
20///
21/// Features draggable hairline dividers, min/max pane constraints, collapse states,
22/// optional drag handle indicators, and horizontal or vertical divider orientations.
23///
24/// # Example
25///
26/// ```rust
27/// use egui_macview::{MacSplitOrientation, MacSplitView, MacTheme};
28///
29/// # let ctx = egui::Context::default();
30/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
31/// let theme = MacTheme::default();
32///
33/// MacSplitView::new("main_split", MacSplitOrientation::Horizontal)
34///     .theme(&theme)
35///     .min_first(100.0)
36///     .show(ui,
37///         |left_ui| { left_ui.label("Sidebar"); },
38///         |right_ui| { right_ui.label("Detail Content"); },
39///     );
40/// # });
41/// # output.textures_delta.clear();
42/// ```
43pub struct MacSplitView<'a> {
44    id: Id,
45    orientation: MacSplitOrientation,
46    pos_override: Option<&'a mut f32>,
47    theme: Option<&'a MacTheme>,
48    min_first: Option<f32>,
49    max_first: Option<f32>,
50    min_second: Option<f32>,
51    collapsed_first: bool,
52    collapsed_second: bool,
53    show_handle: bool,
54    default_ratio: Option<f32>,
55}
56
57impl<'a> MacSplitView<'a> {
58    /// Creates a split view with state tracked automatically via `egui::Id`.
59    pub fn new(
60        id_source: impl std::hash::Hash + std::fmt::Debug,
61        orientation: MacSplitOrientation,
62    ) -> Self {
63        #[allow(deprecated)]
64        let id = Id::new(id_source);
65        Self {
66            id,
67            orientation,
68            pos_override: None,
69            theme: None,
70            min_first: None,
71            max_first: None,
72            min_second: None,
73            collapsed_first: false,
74            collapsed_second: false,
75            show_handle: true,
76            default_ratio: None,
77        }
78    }
79
80    /// Creates a split view binding directly to an external `&mut f32` position variable.
81    pub fn with_pos(
82        id_source: impl std::hash::Hash + std::fmt::Debug,
83        orientation: MacSplitOrientation,
84        pos: &'a mut f32,
85    ) -> Self {
86        let mut view = Self::new(id_source, orientation);
87        view.pos_override = Some(pos);
88        view
89    }
90
91    /// Injects dynamic theme to eliminate hardcoded metrics and colors.
92    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
93        self.theme = Some(theme);
94        self
95    }
96
97    /// Minimum size constraint for the first pane in points.
98    pub fn min_first(mut self, min: f32) -> Self {
99        self.min_first = Some(min);
100        self
101    }
102
103    /// Maximum size constraint for the first pane in points.
104    pub fn max_first(mut self, max: f32) -> Self {
105        self.max_first = Some(max);
106        self
107    }
108
109    /// Minimum size constraint for the second pane in points.
110    pub fn min_second(mut self, min: f32) -> Self {
111        self.min_second = Some(min);
112        self
113    }
114
115    /// Collapses the first pane completely (width/height = 0).
116    pub fn collapsed_first(mut self, collapsed: bool) -> Self {
117        self.collapsed_first = collapsed;
118        self
119    }
120
121    /// Collapses the second pane completely (width/height = 0).
122    pub fn collapsed_second(mut self, collapsed: bool) -> Self {
123        self.collapsed_second = collapsed;
124        self
125    }
126
127    /// Controls visibility of the subtle center drag handle pill on the divider.
128    pub fn show_handle(mut self, show: bool) -> Self {
129        self.show_handle = show;
130        self
131    }
132
133    /// Default split fraction if not yet persisted.
134    pub fn default_ratio(mut self, ratio: f32) -> Self {
135        self.default_ratio = Some(ratio.clamp(0.0, 1.0));
136        self
137    }
138
139    /// Renders the split container and both pane closures, returning their outputs.
140    pub fn show<R1, R2>(
141        mut self,
142        ui: &mut Ui,
143        first: impl FnOnce(&mut Ui) -> R1,
144        second: impl FnOnce(&mut Ui) -> R2,
145    ) -> (R1, R2) {
146        let fallback_theme;
147        let theme = match self.theme {
148            Some(t) => t,
149            None => {
150                fallback_theme = MacTheme::default();
151                &fallback_theme
152            }
153        };
154
155        let metrics = theme.split_view();
156        let divider_thickness = metrics.divider_thickness.points();
157        let hit_thickness = metrics.hit_thickness.points();
158        let min_pane_size = metrics.min_pane_size.points();
159        let handle_len = metrics.handle_length.points();
160        let handle_th = metrics.handle_thickness.points();
161
162        let container_rect = ui.available_rect_before_wrap();
163        let total_span = match self.orientation {
164            MacSplitOrientation::Horizontal => container_rect.width(),
165            MacSplitOrientation::Vertical => container_rect.height(),
166        };
167
168        let default_ratio = self
169            .default_ratio
170            .unwrap_or_else(|| metrics.default_ratio.points());
171        // Determine split position
172        let mut pos = if let Some(ref p) = self.pos_override {
173            **p
174        } else {
175            ui.data_mut(|d| *d.get_temp_mut_or_insert_with(self.id, || total_span * default_ratio))
176        };
177
178        let min_1 = self.min_first.unwrap_or(min_pane_size);
179        let min_2 = self.min_second.unwrap_or(min_pane_size);
180        let max_1 = self
181            .max_first
182            .unwrap_or(total_span - min_2)
183            .min(total_span - min_2);
184        pos = pos.clamp(min_1, max_1.max(min_1));
185
186        let (first_rect, second_rect, divider_line, handle_rect) = if self.collapsed_first {
187            let r2 = container_rect;
188            let r1 = Rect::from_min_size(container_rect.min, Vec2::ZERO);
189            (r1, r2, None, None)
190        } else if self.collapsed_second {
191            let r1 = container_rect;
192            let r2 = Rect::from_min_size(container_rect.max, Vec2::ZERO);
193            (r1, r2, None, None)
194        } else {
195            match self.orientation {
196                MacSplitOrientation::Horizontal => {
197                    let hit_rect = Rect::from_min_max(
198                        Pos2::new(
199                            container_rect.left() + pos - hit_thickness * 0.5,
200                            container_rect.top(),
201                        ),
202                        Pos2::new(
203                            container_rect.left() + pos + hit_thickness * 0.5,
204                            container_rect.bottom(),
205                        ),
206                    );
207                    let hit_resp = ui.interact(
208                        hit_rect,
209                        self.id.with("split_drag"),
210                        egui::Sense::click_and_drag(),
211                    );
212                    if hit_resp.hovered() || hit_resp.dragged() {
213                        ui.output_mut(|o| o.cursor_icon = CursorIcon::ResizeHorizontal);
214                    }
215                    if hit_resp.dragged() {
216                        pos += hit_resp.drag_delta().x;
217                        pos = pos.clamp(min_1, max_1.max(min_1));
218                    }
219
220                    let split_x = container_rect.left() + pos;
221                    let divider_center_x = split_x + divider_thickness * 0.5;
222                    let r1 = Rect::from_min_max(
223                        container_rect.min,
224                        Pos2::new(split_x.min(container_rect.right()), container_rect.bottom()),
225                    );
226                    let r2_left = (split_x + divider_thickness).min(container_rect.right());
227                    let r2 = Rect::from_min_max(
228                        Pos2::new(r2_left, container_rect.top()),
229                        container_rect.max,
230                    );
231
232                    let line = (
233                        Pos2::new(divider_center_x, container_rect.top()),
234                        Pos2::new(divider_center_x, container_rect.bottom()),
235                    );
236                    let h_rect = if self.show_handle {
237                        let center_y = container_rect.center().y;
238                        Some(Rect::from_center_size(
239                            Pos2::new(divider_center_x, center_y),
240                            Vec2::new(handle_th, handle_len),
241                        ))
242                    } else {
243                        None
244                    };
245
246                    (r1, r2, Some(line), h_rect)
247                }
248                MacSplitOrientation::Vertical => {
249                    let hit_rect = Rect::from_min_max(
250                        Pos2::new(
251                            container_rect.left(),
252                            container_rect.top() + pos - hit_thickness * 0.5,
253                        ),
254                        Pos2::new(
255                            container_rect.right(),
256                            container_rect.top() + pos + hit_thickness * 0.5,
257                        ),
258                    );
259                    let hit_resp = ui.interact(
260                        hit_rect,
261                        self.id.with("split_drag"),
262                        egui::Sense::click_and_drag(),
263                    );
264                    if hit_resp.hovered() || hit_resp.dragged() {
265                        ui.output_mut(|o| o.cursor_icon = CursorIcon::ResizeVertical);
266                    }
267                    if hit_resp.dragged() {
268                        pos += hit_resp.drag_delta().y;
269                        pos = pos.clamp(min_1, max_1.max(min_1));
270                    }
271
272                    let split_y = container_rect.top() + pos;
273                    let divider_center_y = split_y + divider_thickness * 0.5;
274                    let r1 = Rect::from_min_max(
275                        container_rect.min,
276                        Pos2::new(container_rect.right(), split_y.min(container_rect.bottom())),
277                    );
278                    let r2_top = (split_y + divider_thickness).min(container_rect.bottom());
279                    let r2 = Rect::from_min_max(
280                        Pos2::new(container_rect.left(), r2_top),
281                        container_rect.max,
282                    );
283
284                    let line = (
285                        Pos2::new(container_rect.left(), divider_center_y),
286                        Pos2::new(container_rect.right(), divider_center_y),
287                    );
288                    let h_rect = if self.show_handle {
289                        let center_x = container_rect.center().x;
290                        Some(Rect::from_center_size(
291                            Pos2::new(center_x, divider_center_y),
292                            Vec2::new(handle_len, handle_th),
293                        ))
294                    } else {
295                        None
296                    };
297
298                    (r1, r2, Some(line), h_rect)
299                }
300            }
301        };
302
303        // Persist updated position
304        if let Some(ref mut p) = self.pos_override {
305            **p = pos;
306        } else {
307            ui.data_mut(|d| d.insert_temp(self.id, pos));
308        }
309
310        // Draw hairline and handle
311        let painter = ui.painter_at(container_rect);
312        if let Some((p1, p2)) = divider_line {
313            painter.line_segment([p1, p2], Stroke::new(divider_thickness, theme.separator()));
314        }
315        if let Some(hr) = handle_rect {
316            painter.rect(
317                hr,
318                CornerRadius::same((handle_th * 0.5) as u8),
319                theme.control(),
320                Stroke::NONE,
321                StrokeKind::Inside,
322            );
323        }
324
325        // Render panes
326        let mut ui1 = ui.new_child(UiBuilder::new().max_rect(first_rect));
327        let r1 = first(&mut ui1);
328
329        let mut ui2 = ui.new_child(UiBuilder::new().max_rect(second_rect));
330        let r2 = second(&mut ui2);
331
332        ui.advance_cursor_after_rect(container_rect);
333
334        (r1, r2)
335    }
336}