Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:31:23.247Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

split_editor_view.rs

696 lines · 23.0 KB · rust
1use std::cmp;
2
3use collections::{HashMap, HashSet};
4use gpui::{
5    AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, Context, DragMoveEvent, Element,
6    Entity, GlobalElementId, Hsla, InspectorElementId, IntoElement, LayoutId, Length,
7    ParentElement, Pixels, StatefulInteractiveElement, Styled, TextStyleRefinement, Window, div,
8    linear_color_stop, linear_gradient, point, px, size,
9};
10use multi_buffer::{Anchor, ExcerptBoundaryInfo};
11use smallvec::smallvec;
12use text::BufferId;
13use theme::ActiveTheme;
14use ui::{h_flex, prelude::*, v_flex};
15
16use gpui::ContentMask;
17
18use crate::{
19    DisplayRow, Editor, EditorSnapshot, EditorStyle, FILE_HEADER_HEIGHT,
20    MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, RowExt, StickyHeaderExcerpt,
21    display_map::Block,
22    element::{EditorElement, SplitSide, header_jump_data, render_buffer_header},
23    scroll::ScrollOffset,
24    split::SplittableEditor,
25};
26
27const RESIZE_HANDLE_WIDTH: f32 = 8.0;
28
29#[derive(Debug, Clone)]
30struct DraggedSplitHandle;
31
32pub struct SplitEditorState {
33    left_ratio: f32,
34    visible_left_ratio: f32,
35    cached_width: Pixels,
36}
37
38impl SplitEditorState {
39    pub fn new(_cx: &mut App) -> Self {
40        Self {
41            left_ratio: 0.5,
42            visible_left_ratio: 0.5,
43            cached_width: px(0.),
44        }
45    }
46
47    #[allow(clippy::misnamed_getters)]
48    pub fn left_ratio(&self) -> f32 {
49        self.visible_left_ratio
50    }
51
52    pub fn right_ratio(&self) -> f32 {
53        1.0 - self.visible_left_ratio
54    }
55
56    fn on_drag_move(
57        &mut self,
58        drag_event: &DragMoveEvent<DraggedSplitHandle>,
59        _window: &mut Window,
60        _cx: &mut Context<Self>,
61    ) {
62        let drag_position = drag_event.event.position;
63        let bounds = drag_event.bounds;
64        let bounds_width = bounds.right() - bounds.left();
65
66        if bounds_width > px(0.) {
67            self.cached_width = bounds_width;
68        }
69
70        let min_ratio = 0.1;
71        let max_ratio = 0.9;
72
73        let new_ratio = (drag_position.x - bounds.left()) / bounds_width;
74        self.visible_left_ratio = new_ratio.clamp(min_ratio, max_ratio);
75    }
76
77    fn commit_ratio(&mut self) {
78        self.left_ratio = self.visible_left_ratio;
79    }
80
81    fn on_double_click(&mut self) {
82        self.left_ratio = 0.5;
83        self.visible_left_ratio = 0.5;
84    }
85}
86
87#[derive(IntoElement)]
88pub struct SplitEditorView {
89    splittable_editor: Entity<SplittableEditor>,
90    style: EditorStyle,
91    split_state: Entity<SplitEditorState>,
92}
93
94impl SplitEditorView {
95    pub fn new(
96        splittable_editor: Entity<SplittableEditor>,
97        style: EditorStyle,
98        split_state: Entity<SplitEditorState>,
99    ) -> Self {
100        Self {
101            splittable_editor,
102            style,
103            split_state,
104        }
105    }
106}
107
108fn render_resize_handle(
109    state: &Entity<SplitEditorState>,
110    separator_color: Hsla,
111    _window: &mut Window,
112    _cx: &mut App,
113) -> AnyElement {
114    let state_for_click = state.clone();
115
116    div()
117        .id("split-resize-container")
118        .relative()
119        .h_full()
120        .flex_shrink_0()
121        .w(px(1.))
122        .bg(separator_color)
123        .child(
124            div()
125                .id("split-resize-handle")
126                .absolute()
127                .left(px(-RESIZE_HANDLE_WIDTH / 2.0))
128                .w(px(RESIZE_HANDLE_WIDTH))
129                .h_full()
130                .cursor_col_resize()
131                .block_mouse_except_scroll()
132                .on_click(move |event, _, cx| {
133                    if event.click_count() >= 2 {
134                        state_for_click.update(cx, |state, _| {
135                            state.on_double_click();
136                        });
137                    }
138                    cx.stop_propagation();
139                })
140                .on_drag(DraggedSplitHandle, |_, _, _, cx| cx.new(|_| gpui::Empty)),
141        )
142        .into_any_element()
143}
144
145impl RenderOnce for SplitEditorView {
146    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
147        let splittable_editor = self.splittable_editor.read(cx);
148
149        assert!(
150            splittable_editor.lhs_editor().is_some(),
151            "`SplitEditorView` requires `SplittableEditor` to be in split mode"
152        );
153
154        let lhs_editor = splittable_editor.lhs_editor().unwrap().clone();
155        let rhs_editor = splittable_editor.rhs_editor().clone();
156
157        let mut lhs = EditorElement::new(&lhs_editor, self.style.clone());
158        let mut rhs = EditorElement::new(&rhs_editor, self.style.clone());
159
160        lhs.set_split_side(SplitSide::Left);
161        rhs.set_split_side(SplitSide::Right);
162
163        let left_ratio = self.split_state.read(cx).left_ratio();
164        let right_ratio = self.split_state.read(cx).right_ratio();
165
166        let separator_color = cx.theme().colors().border_variant;
167
168        let resize_handle = render_resize_handle(&self.split_state, separator_color, window, cx);
169
170        let state_for_drag = self.split_state.downgrade();
171        let state_for_drop = self.split_state.downgrade();
172
173        let buffer_headers = SplitBufferHeadersElement::new(
174            lhs_editor.clone(),
175            rhs_editor.clone(),
176            self.style.clone(),
177        );
178
179        let lhs_editor_for_order = lhs_editor;
180        let rhs_editor_for_order = rhs_editor;
181
182        div()
183            .id("split-editor-view-container")
184            .size_full()
185            .relative()
186            .child(
187                h_flex()
188                    .with_dynamic_prepaint_order(move |_window, cx| {
189                        let lhs_needs = lhs_editor_for_order.read(cx).has_autoscroll_request();
190                        let rhs_needs = rhs_editor_for_order.read(cx).has_autoscroll_request();
191                        match (lhs_needs, rhs_needs) {
192                            (false, true) => smallvec![2, 1, 0],
193                            _ => smallvec![0, 1, 2],
194                        }
195                    })
196                    .id("split-editor-view")
197                    .size_full()
198                    .on_drag_move::<DraggedSplitHandle>(move |event, window, cx| {
199                        state_for_drag
200                            .update(cx, |state, cx| {
201                                state.on_drag_move(event, window, cx);
202                            })
203                            .ok();
204                    })
205                    .on_drop::<DraggedSplitHandle>(move |_, _, cx| {
206                        state_for_drop
207                            .update(cx, |state, _| {
208                                state.commit_ratio();
209                            })
210                            .ok();
211                    })
212                    .child(
213                        div()
214                            .id("split-editor-left")
215                            .flex_shrink_1()
216                            .min_w_0()
217                            .h_full()
218                            .flex_basis(DefiniteLength::Fraction(left_ratio))
219                            .overflow_hidden()
220                            .child(lhs),
221                    )
222                    .child(resize_handle)
223                    .child(
224                        div()
225                            .id("split-editor-right")
226                            .flex_shrink_1()
227                            .min_w_0()
228                            .h_full()
229                            .flex_basis(DefiniteLength::Fraction(right_ratio))
230                            .overflow_hidden()
231                            .child(rhs),
232                    ),
233            )
234            .child(buffer_headers)
235    }
236}
237
238struct SplitBufferHeadersElement {
239    lhs_editor: Entity<Editor>,
240    rhs_editor: Entity<Editor>,
241    style: EditorStyle,
242}
243
244impl SplitBufferHeadersElement {
245    fn new(lhs_editor: Entity<Editor>, rhs_editor: Entity<Editor>, style: EditorStyle) -> Self {
246        Self {
247            lhs_editor,
248            rhs_editor,
249            style,
250        }
251    }
252}
253
254struct BufferHeaderLayout {
255    element: AnyElement,
256}
257
258struct SplitBufferHeadersPrepaintState {
259    content_bounds: Bounds<Pixels>,
260    sticky_header: Option<AnyElement>,
261    non_sticky_headers: Vec<BufferHeaderLayout>,
262}
263
264impl IntoElement for SplitBufferHeadersElement {
265    type Element = Self;
266
267    fn into_element(self) -> Self::Element {
268        self
269    }
270}
271
272impl Element for SplitBufferHeadersElement {
273    type RequestLayoutState = ();
274    type PrepaintState = SplitBufferHeadersPrepaintState;
275
276    fn id(&self) -> Option<gpui::ElementId> {
277        Some("split-buffer-headers".into())
278    }
279
280    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
281        None
282    }
283
284    fn request_layout(
285        &mut self,
286        _id: Option<&GlobalElementId>,
287        _inspector_id: Option<&InspectorElementId>,
288        window: &mut Window,
289        _cx: &mut App,
290    ) -> (LayoutId, Self::RequestLayoutState) {
291        let mut style = gpui::Style::default();
292        style.position = gpui::Position::Absolute;
293        style.inset.top = DefiniteLength::Fraction(0.0).into();
294        style.inset.left = DefiniteLength::Fraction(0.0).into();
295        style.size.width = Length::Definite(DefiniteLength::Fraction(1.0));
296        style.size.height = Length::Definite(DefiniteLength::Fraction(1.0));
297        let layout_id = window.request_layout(style, [], _cx);
298        (layout_id, ())
299    }
300
301    fn prepaint(
302        &mut self,
303        _id: Option<&GlobalElementId>,
304        _inspector_id: Option<&InspectorElementId>,
305        bounds: Bounds<Pixels>,
306        _request_layout: &mut Self::RequestLayoutState,
307        window: &mut Window,
308        cx: &mut App,
309    ) -> Self::PrepaintState {
310        if bounds.size.width <= px(0.) || bounds.size.height <= px(0.) {
311            return SplitBufferHeadersPrepaintState {
312                content_bounds: bounds,
313                sticky_header: None,
314                non_sticky_headers: Vec::new(),
315            };
316        }
317
318        let rem_size = self.rem_size();
319        let text_style = TextStyleRefinement {
320            font_size: Some(self.style.text.font_size),
321            line_height: Some(self.style.text.line_height),
322            ..Default::default()
323        };
324
325        window.with_rem_size(rem_size, |window| {
326            window.with_text_style(Some(text_style), |window| {
327                Self::prepaint_inner(self, bounds, window, cx)
328            })
329        })
330    }
331
332    fn paint(
333        &mut self,
334        _id: Option<&GlobalElementId>,
335        _inspector_id: Option<&InspectorElementId>,
336        _bounds: Bounds<Pixels>,
337        _request_layout: &mut Self::RequestLayoutState,
338        prepaint: &mut Self::PrepaintState,
339        window: &mut Window,
340        cx: &mut App,
341    ) {
342        let rem_size = self.rem_size();
343        let text_style = TextStyleRefinement {
344            font_size: Some(self.style.text.font_size),
345            line_height: Some(self.style.text.line_height),
346            ..Default::default()
347        };
348
349        window.with_rem_size(rem_size, |window| {
350            window.with_text_style(Some(text_style), |window| {
351                window.with_content_mask(
352                    Some(ContentMask {
353                        bounds: prepaint.content_bounds,
354                    }),
355                    |window| {
356                        for header_layout in &mut prepaint.non_sticky_headers {
357                            header_layout.element.paint(window, cx);
358                        }
359
360                        if let Some(mut sticky_header) = prepaint.sticky_header.take() {
361                            sticky_header.paint(window, cx);
362                        }
363                    },
364                );
365            });
366        });
367    }
368}
369
370impl SplitBufferHeadersElement {
371    fn rem_size(&self) -> Option<Pixels> {
372        match self.style.text.font_size {
373            AbsoluteLength::Pixels(pixels) => {
374                let rem_size_scale = {
375                    let default_font_size_scale = 14. / ui::BASE_REM_SIZE_IN_PX;
376                    let default_font_size_delta = 1. - default_font_size_scale;
377                    1. + default_font_size_delta
378                };
379
380                Some(pixels * rem_size_scale)
381            }
382            AbsoluteLength::Rems(rems) => Some(rems.to_pixels(ui::BASE_REM_SIZE_IN_PX.into())),
383        }
384    }
385
386    fn prepaint_inner(
387        &mut self,
388        bounds: Bounds<Pixels>,
389        window: &mut Window,
390        cx: &mut App,
391    ) -> SplitBufferHeadersPrepaintState {
392        let line_height = window.line_height();
393
394        let snapshot = self
395            .rhs_editor
396            .update(cx, |editor, cx| editor.snapshot(window, cx));
397        let scroll_position = snapshot.scroll_position();
398
399        // Compute right margin to avoid overlapping the scrollbar
400        let content_bounds = self.content_bounds(bounds, cx);
401        let available_width = (content_bounds.size.width
402            - self.rhs_editor.read(cx).last_right_margin())
403        .max(Pixels::ZERO);
404
405        let visible_height_in_lines = content_bounds.size.height / line_height;
406        let max_row = snapshot.max_point().row();
407        let start_row = cmp::min(DisplayRow(scroll_position.y.floor() as u32), max_row);
408        let end_row = cmp::min(
409            (scroll_position.y + visible_height_in_lines as f64).ceil() as u32,
410            max_row.next_row().0,
411        );
412        let end_row = DisplayRow(end_row);
413
414        let (selected_buffer_ids, latest_selection_anchors) =
415            self.compute_selection_info(&snapshot, cx);
416
417        let sticky_header = if snapshot.buffer_snapshot().show_headers() {
418            snapshot
419                .sticky_header_excerpt(scroll_position.y)
420                .map(|sticky_excerpt| {
421                    self.build_sticky_header(
422                        sticky_excerpt,
423                        &snapshot,
424                        scroll_position,
425                        content_bounds,
426                        available_width,
427                        line_height,
428                        &selected_buffer_ids,
429                        &latest_selection_anchors,
430                        start_row,
431                        end_row,
432                        window,
433                        cx,
434                    )
435                })
436        } else {
437            None
438        };
439
440        let sticky_header_excerpt_id = snapshot
441            .sticky_header_excerpt(scroll_position.y)
442            .map(|e| e.excerpt);
443
444        let non_sticky_headers = self.build_non_sticky_headers(
445            &snapshot,
446            scroll_position,
447            content_bounds,
448            available_width,
449            line_height,
450            start_row,
451            end_row,
452            &selected_buffer_ids,
453            &latest_selection_anchors,
454            sticky_header_excerpt_id,
455            window,
456            cx,
457        );
458
459        SplitBufferHeadersPrepaintState {
460            content_bounds,
461            sticky_header,
462            non_sticky_headers,
463        }
464    }
465
466    fn content_bounds(&self, bounds: Bounds<Pixels>, cx: &App) -> Bounds<Pixels> {
467        // Left hand side and right hand side horizontal scrollbars are
468        // independent, so we clip the bottom if either is visible.
469        let horizontal_scrollbar_height =
470            (self.lhs_editor.read(cx).last_horizontal_scrollbar_visible()
471                || self.rhs_editor.read(cx).last_horizontal_scrollbar_visible())
472            .then_some(self.style.scrollbar_width)
473            .unwrap_or(Pixels::ZERO);
474
475        Bounds::new(
476            bounds.origin,
477            size(
478                bounds.size.width,
479                (bounds.size.height - horizontal_scrollbar_height).max(Pixels::ZERO),
480            ),
481        )
482    }
483
484    fn compute_selection_info(
485        &self,
486        snapshot: &EditorSnapshot,
487        cx: &App,
488    ) -> (HashSet<BufferId>, HashMap<BufferId, Anchor>) {
489        let editor = self.rhs_editor.read(cx);
490        let all_selections = editor
491            .selections
492            .all::<crate::Point>(&snapshot.display_snapshot);
493        let all_anchor_selections = editor.selections.all_anchors(&snapshot.display_snapshot);
494
495        let mut selected_buffer_ids = HashSet::default();
496        for selection in &all_selections {
497            for buffer_id in snapshot
498                .buffer_snapshot()
499                .buffer_ids_for_range(selection.range())
500            {
501                selected_buffer_ids.insert(buffer_id);
502            }
503        }
504
505        let mut anchors_by_buffer: HashMap<BufferId, (usize, Anchor)> = HashMap::default();
506        for selection in all_anchor_selections.iter() {
507            let head = selection.head();
508            if let Some((text_anchor, _)) = snapshot.buffer_snapshot().anchor_to_buffer_anchor(head)
509            {
510                anchors_by_buffer
511                    .entry(text_anchor.buffer_id)
512                    .and_modify(|(latest_id, latest_anchor)| {
513                        if selection.id > *latest_id {
514                            *latest_id = selection.id;
515                            *latest_anchor = head;
516                        }
517                    })
518                    .or_insert((selection.id, head));
519            }
520        }
521        let latest_selection_anchors = anchors_by_buffer
522            .into_iter()
523            .map(|(buffer_id, (_, anchor))| (buffer_id, anchor))
524            .collect();
525
526        (selected_buffer_ids, latest_selection_anchors)
527    }
528
529    fn build_sticky_header(
530        &self,
531        StickyHeaderExcerpt { excerpt }: StickyHeaderExcerpt<'_>,
532        snapshot: &EditorSnapshot,
533        scroll_position: gpui::Point<ScrollOffset>,
534        bounds: Bounds<Pixels>,
535        available_width: Pixels,
536        line_height: Pixels,
537        selected_buffer_ids: &HashSet<BufferId>,
538        latest_selection_anchors: &HashMap<BufferId, Anchor>,
539        start_row: DisplayRow,
540        end_row: DisplayRow,
541        window: &mut Window,
542        cx: &mut App,
543    ) -> AnyElement {
544        let jump_data = header_jump_data(
545            snapshot,
546            DisplayRow(scroll_position.y as u32),
547            FILE_HEADER_HEIGHT + MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
548            excerpt,
549            latest_selection_anchors,
550        );
551
552        let editor_bg_color = cx.theme().colors().editor_background;
553        let selected = selected_buffer_ids.contains(&excerpt.buffer_id());
554
555        let mut header = v_flex()
556            .id("sticky-buffer-header")
557            .w(available_width)
558            .relative()
559            .child(
560                div()
561                    .w(available_width)
562                    .h(FILE_HEADER_HEIGHT as f32 * line_height)
563                    .bg(linear_gradient(
564                        0.,
565                        linear_color_stop(editor_bg_color.opacity(0.), 0.),
566                        linear_color_stop(editor_bg_color, 0.6),
567                    ))
568                    .absolute()
569                    .top_0(),
570            )
571            .child(
572                render_buffer_header(
573                    &self.rhs_editor,
574                    excerpt,
575                    false,
576                    selected,
577                    true,
578                    jump_data,
579                    window,
580                    cx,
581                )
582                .into_any_element(),
583            )
584            .into_any_element();
585
586        let mut origin = bounds.origin;
587
588        for (block_row, block) in snapshot.blocks_in_range(start_row..end_row) {
589            if !block.is_buffer_header() {
590                continue;
591            }
592
593            if block_row.0 <= scroll_position.y as u32 {
594                continue;
595            }
596
597            let max_row = block_row.0.saturating_sub(FILE_HEADER_HEIGHT);
598            let offset = scroll_position.y - max_row as f64;
599
600            if offset > 0.0 {
601                origin.y -= Pixels::from(offset * f64::from(line_height));
602            }
603            break;
604        }
605
606        let available_size = size(
607            AvailableSpace::Definite(available_width),
608            AvailableSpace::MinContent,
609        );
610
611        Self::prepaint_header(&mut header, origin, available_size, bounds, window, cx);
612
613        header
614    }
615
616    fn build_non_sticky_headers(
617        &self,
618        snapshot: &EditorSnapshot,
619        scroll_position: gpui::Point<ScrollOffset>,
620        bounds: Bounds<Pixels>,
621        available_width: Pixels,
622        line_height: Pixels,
623        start_row: DisplayRow,
624        end_row: DisplayRow,
625        selected_buffer_ids: &HashSet<BufferId>,
626        latest_selection_anchors: &HashMap<BufferId, Anchor>,
627        sticky_header: Option<&ExcerptBoundaryInfo>,
628        window: &mut Window,
629        cx: &mut App,
630    ) -> Vec<BufferHeaderLayout> {
631        let mut headers = Vec::new();
632
633        for (block_row, block) in snapshot.blocks_in_range(start_row..end_row) {
634            let (excerpt, is_folded) = match block {
635                Block::BufferHeader { excerpt, .. } => {
636                    if sticky_header == Some(excerpt) {
637                        continue;
638                    }
639                    (excerpt, false)
640                }
641                Block::FoldedBuffer { first_excerpt, .. } => (first_excerpt, true),
642                // ExcerptBoundary is just a separator line, not a buffer header
643                Block::ExcerptBoundary { .. } | Block::Custom(_) | Block::Spacer { .. } => continue,
644            };
645
646            let selected = selected_buffer_ids.contains(&excerpt.buffer_id());
647            let jump_data = header_jump_data(
648                snapshot,
649                block_row,
650                block.height(),
651                excerpt,
652                latest_selection_anchors,
653            );
654
655            let mut header = render_buffer_header(
656                &self.rhs_editor,
657                excerpt,
658                is_folded,
659                selected,
660                false,
661                jump_data,
662                window,
663                cx,
664            )
665            .into_any_element();
666
667            let y_offset = (block_row.0 as f64 - scroll_position.y) * f64::from(line_height);
668            let origin = point(bounds.origin.x, bounds.origin.y + Pixels::from(y_offset));
669
670            let available_size = size(
671                AvailableSpace::Definite(available_width),
672                AvailableSpace::MinContent,
673            );
674
675            Self::prepaint_header(&mut header, origin, available_size, bounds, window, cx);
676
677            headers.push(BufferHeaderLayout { element: header });
678        }
679
680        headers
681    }
682
683    fn prepaint_header(
684        header: &mut AnyElement,
685        origin: gpui::Point<Pixels>,
686        available_size: gpui::Size<AvailableSpace>,
687        bounds: Bounds<Pixels>,
688        window: &mut Window,
689        cx: &mut App,
690    ) {
691        window.with_content_mask(Some(ContentMask { bounds }), |window| {
692            header.prepaint_as_root(origin, available_size, window, cx);
693        });
694    }
695}
696
Served at tenant.openagents/omega Member data and write actions are omitted.