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