Skip to repository content696 lines · 23.0 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:31:30.498Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
split_editor_view.rs
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