Skip to main content

twrite_gpui/editor/
geometry.rs

1use gpui::{Pixels, Point, TextRun, Window, point, px};
2use twrite_core::Selection;
3
4use crate::canvas::{LineMetrics, RunFonts, build_line_text_runs};
5
6use super::{Editor, VisibleLineLayout};
7
8/// Finds the visible line containing vertical position `y` via binary search.
9///
10/// `lines` is sorted by `top` (constructed in paint order in prepaint).
11pub(crate) fn find_visible_line(
12    lines: &[VisibleLineLayout],
13    y: Pixels,
14) -> Option<&VisibleLineLayout> {
15    let idx = lines.partition_point(|l| l.top <= y);
16    let line = lines.get(idx.checked_sub(1)?)?;
17    (y < line.bottom).then_some(line)
18}
19
20impl Editor {
21    /// Scrolls the viewport upward by a given number of lines.
22    pub fn scroll_up(&mut self, count: usize) {
23        self.scroll_row = self.scroll_row.saturating_sub(count);
24    }
25
26    /// Scrolls the viewport downward by a given number of lines.
27    pub fn scroll_down(&mut self, count: usize) {
28        let total_lines = self.buffer.len_lines();
29        self.scroll_row = (self.scroll_row + count).min(total_lines.saturating_sub(1));
30    }
31
32    /// Scrolls the viewport by `count` rows and clamps the cursor into the
33    /// visible range (Vim `Ctrl+E` / `Ctrl+Y` style).
34    ///
35    /// Set `scroll_down` to `true` to scroll toward the end of the document,
36    /// `false` to scroll toward the beginning.
37    ///
38    /// If the cursor row is still visible after the scroll it is not moved.
39    /// If it scrolled above the viewport the cursor moves to the first visible
40    /// row. If it scrolled below the viewport the cursor moves to the last
41    /// visible row. The cursor column is preserved and clamped to the target
42    /// line length by the buffer.
43    ///
44    /// Pass `select = true` when Shift is held to extend the selection instead
45    /// of collapsing it.
46    ///
47    /// The caller is responsible for running `on_selection_change` hooks,
48    /// `flush_effects`, `sync_search_state`, and `cx.notify()` afterwards,
49    /// matching the post-processing every other cursor-moving key path runs.
50    pub fn scroll_and_clamp_cursor(&mut self, count: usize, scroll_down: bool, select: bool) {
51        if scroll_down {
52            self.scroll_down(count);
53        } else {
54            self.scroll_up(count);
55        }
56
57        let total_lines = self.buffer.len_lines();
58        if total_lines == 0 {
59            return;
60        }
61
62        let visible_row_range =
63            if !self.visible_lines.is_empty() && self.visible_lines[0].row == self.scroll_row {
64                let first = self.visible_lines[0].row;
65                let last = self.visible_lines.last().unwrap().row;
66                Some(first..=last)
67            } else if let Some(bounds) = self.last_bounds {
68                let line_height = self.config.line_height;
69                let viewport_height = bounds.size.height;
70                let visible_row_count = if line_height > px(0.0) {
71                    (viewport_height / line_height).floor() as usize
72                } else {
73                    1
74                };
75                let first = self.scroll_row;
76                let last = (self.scroll_row + visible_row_count.saturating_sub(1))
77                    .min(total_lines.saturating_sub(1));
78                Some(first..=last)
79            } else {
80                None
81            };
82
83        let Some(visible_rows) = visible_row_range else {
84            return;
85        };
86
87        let cursor_point = self.buffer.cursor_point();
88        let cursor_col = cursor_point.column;
89
90        let target_row = if cursor_point.row < *visible_rows.start() {
91            *visible_rows.start()
92        } else if cursor_point.row > *visible_rows.end() {
93            *visible_rows.end()
94        } else {
95            return;
96        };
97
98        let target_offset = self
99            .buffer
100            .point_to_offset(twrite_core::Point::new(target_row, cursor_col));
101        self.move_cursor_to(target_offset, select);
102    }
103
104    /// Moves the cursor to `new_offset`, expanding or creating a selection if `select` is true.
105    pub fn move_cursor_to(&mut self, new_offset: usize, select: bool) {
106        if select {
107            let anchor = self
108                .selection
109                .map(|s| s.anchor)
110                .unwrap_or_else(|| self.buffer.cursor_offset());
111            self.buffer.set_cursor_offset(new_offset);
112            if anchor != new_offset {
113                self.selection = Some(Selection::range(anchor, new_offset));
114            } else {
115                self.selection = None;
116            }
117        } else {
118            self.buffer.set_cursor_offset(new_offset);
119            self.selection = None;
120        }
121    }
122
123    /// Scrolls the viewport so that the cursor is visible.
124    ///
125    /// Ensures a 1-line margin above and below the cursor when possible.
126    pub fn scroll_to_cursor(&mut self, window: Option<&Window>) {
127        let total_lines = self.buffer.len_lines();
128        if total_lines == 0 {
129            self.scroll_row = 0;
130            return;
131        }
132
133        let cursor_row = self
134            .buffer
135            .cursor_point()
136            .row
137            .min(total_lines.saturating_sub(1));
138
139        let margin_lines = 1;
140        if cursor_row < self.scroll_row + margin_lines {
141            self.scroll_row = cursor_row.saturating_sub(margin_lines);
142            return;
143        }
144
145        let bounds = match self.last_bounds {
146            Some(b) => b,
147            None => return,
148        };
149
150        let viewport_height = bounds.size.height;
151        if viewport_height <= px(0.0) {
152            return;
153        }
154
155        let line_height = self.config.line_height;
156        let margin = line_height * margin_lines as f32;
157
158        if self.config.line_wrap
159            && let Some(win) = window
160        {
161            let gutter_width = if self.config.line_numbers {
162                px(48.0)
163            } else {
164                px(0.0)
165            };
166            let wrap_width = Some((bounds.size.width - gutter_width - px(24.0)).max(px(50.0)));
167            let font = self.resolved_base_font(&win.text_style().font());
168
169            let get_row_visual_lines = |row: usize| -> usize {
170                let raw_line = self.buffer.line_to_string(row);
171                let line_text = raw_line.trim_end_matches(['\r', '\n']);
172                if line_text.is_empty() {
173                    return 1;
174                }
175                let wraps = self
176                    .highlighter
177                    .as_deref()
178                    .map(|h| h.should_wrap_line(&self.buffer, row))
179                    .unwrap_or(true);
180                if !wraps {
181                    return 1;
182                }
183                let runs = [TextRun {
184                    len: line_text.len(),
185                    font: font.clone(),
186                    color: self.theme.foreground,
187                    background_color: None,
188                    underline: None,
189                    strikethrough: None,
190                }];
191                win.text_system()
192                    .shape_text(
193                        line_text.to_string().into(),
194                        self.config.font_size,
195                        &runs,
196                        wrap_width,
197                        None,
198                    )
199                    .ok()
200                    .and_then(|mut l| l.pop())
201                    .map(|l| l.wrap_boundaries.len() + 1)
202                    .unwrap_or(1)
203            };
204
205            let mut accumulated = line_height * get_row_visual_lines(cursor_row) as f32;
206            let mut new_scroll_row = cursor_row;
207
208            while new_scroll_row > 0 {
209                let prev_lines = get_row_visual_lines(new_scroll_row - 1);
210                let prev_height = line_height * prev_lines as f32;
211                if accumulated + prev_height + margin > viewport_height {
212                    break;
213                }
214                accumulated += prev_height;
215                new_scroll_row -= 1;
216            }
217
218            if new_scroll_row > self.scroll_row {
219                self.scroll_row = new_scroll_row;
220            }
221        } else {
222            let visible_lines = (viewport_height / line_height).floor() as usize;
223            let effective_visible = visible_lines.saturating_sub(margin_lines).max(1);
224
225            if cursor_row >= self.scroll_row + effective_visible {
226                self.scroll_row = cursor_row.saturating_sub(effective_visible.saturating_sub(1));
227            }
228        }
229    }
230
231    /// Calculates the byte offset in the text buffer corresponding to a window pixel position.
232    ///
233    /// Shares [`crate::layout_cache::LayoutCache`] inputs with prepaint: the highlight/conceal work
234    /// for the target row is a cache hit unless the buffer changed since paint.
235    pub fn offset_for_position(&mut self, pos: Point<Pixels>, window: &Window) -> usize {
236        let bounds = match self.last_bounds {
237            Some(b) => b,
238            None => return self.buffer.cursor_offset(),
239        };
240
241        let total_lines = self.buffer.len_lines();
242        if total_lines == 0 {
243            return 0;
244        }
245
246        if !self.visible_lines.is_empty() {
247            if pos.y < self.visible_lines[0].top {
248                return self.visible_lines[0].line_start_byte;
249            }
250
251            let last = self.visible_lines.last().unwrap();
252            if pos.y >= last.bottom {
253                return (last.line_start_byte + last.line_len_bytes).min(self.buffer.len_bytes());
254            }
255
256            let target_line = match find_visible_line(&self.visible_lines, pos.y) {
257                Some(l) => l,
258                None => last,
259            };
260
261            let row = target_line.row;
262            let line_start_byte = target_line.line_start_byte;
263            let task_state = target_line.task_state;
264            let text_origin_x = target_line.text_origin_x;
265            let line_top = target_line.top;
266            let raw_line = self.buffer.line_to_string(row);
267            let line_text = raw_line.trim_end_matches(['\r', '\n']);
268
269            if line_text.is_empty() || pos.x <= text_origin_x {
270                return line_start_byte;
271            }
272
273            let base_font_size = self.config.font_size;
274            let base_line_height = self.config.line_height;
275            let cursor_row = self.buffer.cursor_point().row;
276            let highlighter_rev = self.highlighter_rev;
277            let host_font = window.text_style().font();
278            let font = self.resolved_base_font(&host_font);
279            let code_font = self.resolved_code_font(&host_font);
280            let cached = self.layout_cache.cached_input(
281                &self.buffer,
282                self.highlighter.as_deref(),
283                highlighter_rev,
284                cursor_row,
285                row,
286                line_text,
287            );
288            let concealed = &cached.concealed;
289
290            // Mirror paint: headings shape at a scaled font size, so hit-test
291            // must use the same metrics or clicks drift on concealed lines.
292            let metrics = LineMetrics::for_line(
293                line_text,
294                &concealed.display_text,
295                &cached.spans,
296                base_font_size,
297                base_line_height,
298            );
299
300            let is_checked_task =
301                task_state == Some(true) && line_text.len() != concealed.display_text.len();
302
303            let fonts = RunFonts {
304                base: &font,
305                code: &code_font,
306            };
307            let runs = build_line_text_runs(
308                &concealed.display_text,
309                &concealed.spans,
310                None,
311                &fonts,
312                &self.theme,
313                metrics.is_code_block,
314                is_checked_task,
315            );
316
317            let wrap_width = if self.config.line_wrap && cached.allow_wrap {
318                let available = bounds.size.width - (text_origin_x - bounds.left()) - px(12.0);
319                Some(available.max(px(50.0)))
320            } else {
321                None
322            };
323
324            let text_line = window
325                .text_system()
326                .shape_text(
327                    concealed.display_text.clone().into(),
328                    metrics.font_size,
329                    &runs,
330                    wrap_width,
331                    None,
332                )
333                .ok()
334                .and_then(|mut l| l.pop())
335                .unwrap_or_default();
336
337            let line_rel_y = (pos.y - line_top).max(px(0.0));
338            let line_rel_x = (pos.x - text_origin_x).max(px(0.0));
339            let rel_pos = point(line_rel_x, line_rel_y);
340
341            let col_display = text_line
342                .closest_index_for_position(rel_pos, metrics.line_height)
343                .unwrap_or_else(|idx| idx);
344
345            let col_src = concealed.display_to_source(col_display);
346            return line_start_byte + col_src.min(line_text.len());
347        }
348
349        0
350    }
351
352    /// Returns the window pixel coordinates (X, Y) at the bottom of the active cursor.
353    ///
354    /// This value is automatically computed and cached during each canvas render pass.
355    /// Returns `None` if the editor has not yet been rendered, or if the cursor is scrolled
356    /// outside the visible viewport.
357    pub fn cursor_pixel_position(&self) -> Option<Point<Pixels>> {
358        self.last_cursor_pixel
359    }
360
361    /// Returns the target URL if `pos` is over a hyperlink.
362    pub fn link_at_position(&self, pos: Point<Pixels>) -> Option<String> {
363        let bounds = self.last_bounds?;
364        if !bounds.contains(&pos) {
365            return None;
366        }
367
368        if let Some(line) = find_visible_line(&self.visible_lines, pos.y) {
369            for link in &line.links {
370                if link.bounds.contains(&pos) {
371                    return Some(link.url.clone());
372                }
373            }
374        }
375
376        None
377    }
378
379    pub(crate) fn is_position_over_task_checkbox(
380        &self,
381        pos: Point<Pixels>,
382        _window: &Window,
383    ) -> bool {
384        let bounds = match self.last_bounds {
385            Some(b) => b,
386            None => return false,
387        };
388
389        if !bounds.contains(&pos) {
390            return false;
391        }
392
393        if let Some(line) = find_visible_line(&self.visible_lines, pos.y)
394            && line.is_task_checkbox
395        {
396            return pos.x >= line.checkbox_box_x && pos.x <= line.checkbox_box_x + px(22.0);
397        }
398
399        false
400    }
401}