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    /// Moves the cursor to `new_offset`, expanding or creating a selection if `select` is true.
33    pub fn move_cursor_to(&mut self, new_offset: usize, select: bool) {
34        if select {
35            let anchor = self
36                .selection
37                .map(|s| s.anchor)
38                .unwrap_or_else(|| self.buffer.cursor_offset());
39            self.buffer.set_cursor_offset(new_offset);
40            if anchor != new_offset {
41                self.selection = Some(Selection::range(anchor, new_offset));
42            } else {
43                self.selection = None;
44            }
45        } else {
46            self.buffer.set_cursor_offset(new_offset);
47            self.selection = None;
48        }
49    }
50
51    /// Scrolls the viewport so that the cursor is visible.
52    ///
53    /// Ensures a 1-line margin above and below the cursor when possible.
54    pub fn scroll_to_cursor(&mut self, window: Option<&Window>) {
55        let total_lines = self.buffer.len_lines();
56        if total_lines == 0 {
57            self.scroll_row = 0;
58            return;
59        }
60
61        let cursor_row = self
62            .buffer
63            .cursor_point()
64            .row
65            .min(total_lines.saturating_sub(1));
66
67        let margin_lines = 1;
68        if cursor_row < self.scroll_row + margin_lines {
69            self.scroll_row = cursor_row.saturating_sub(margin_lines);
70            return;
71        }
72
73        let bounds = match self.last_bounds {
74            Some(b) => b,
75            None => return,
76        };
77
78        let viewport_height = bounds.size.height;
79        if viewport_height <= px(0.0) {
80            return;
81        }
82
83        let line_height = self.config.line_height;
84        let margin = line_height * margin_lines as f32;
85
86        if self.config.line_wrap
87            && let Some(win) = window
88        {
89            let gutter_width = if self.config.line_numbers {
90                px(48.0)
91            } else {
92                px(0.0)
93            };
94            let wrap_width = Some((bounds.size.width - gutter_width - px(24.0)).max(px(50.0)));
95            let font = self.resolved_base_font(&win.text_style().font());
96
97            let get_row_visual_lines = |row: usize| -> usize {
98                let raw_line = self.buffer.line_to_string(row);
99                let line_text = raw_line.trim_end_matches(['\r', '\n']);
100                if line_text.is_empty() {
101                    return 1;
102                }
103                let wraps = self
104                    .highlighter
105                    .as_deref()
106                    .map(|h| h.should_wrap_line(&self.buffer, row))
107                    .unwrap_or(true);
108                if !wraps {
109                    return 1;
110                }
111                let runs = [TextRun {
112                    len: line_text.len(),
113                    font: font.clone(),
114                    color: self.theme.foreground,
115                    background_color: None,
116                    underline: None,
117                    strikethrough: None,
118                }];
119                win.text_system()
120                    .shape_text(
121                        line_text.to_string().into(),
122                        self.config.font_size,
123                        &runs,
124                        wrap_width,
125                        None,
126                    )
127                    .ok()
128                    .and_then(|mut l| l.pop())
129                    .map(|l| l.wrap_boundaries.len() + 1)
130                    .unwrap_or(1)
131            };
132
133            let mut accumulated = line_height * get_row_visual_lines(cursor_row) as f32;
134            let mut new_scroll_row = cursor_row;
135
136            while new_scroll_row > 0 {
137                let prev_lines = get_row_visual_lines(new_scroll_row - 1);
138                let prev_height = line_height * prev_lines as f32;
139                if accumulated + prev_height + margin > viewport_height {
140                    break;
141                }
142                accumulated += prev_height;
143                new_scroll_row -= 1;
144            }
145
146            if new_scroll_row > self.scroll_row {
147                self.scroll_row = new_scroll_row;
148            }
149        } else {
150            let visible_lines = (viewport_height / line_height).floor() as usize;
151            let effective_visible = visible_lines.saturating_sub(margin_lines).max(1);
152
153            if cursor_row >= self.scroll_row + effective_visible {
154                self.scroll_row = cursor_row.saturating_sub(effective_visible.saturating_sub(1));
155            }
156        }
157    }
158
159    /// Calculates the byte offset in the text buffer corresponding to a window pixel position.
160    ///
161    /// Shares [`crate::layout_cache::LayoutCache`] inputs with prepaint: the highlight/conceal work
162    /// for the target row is a cache hit unless the buffer changed since paint.
163    pub fn offset_for_position(&mut self, pos: Point<Pixels>, window: &Window) -> usize {
164        let bounds = match self.last_bounds {
165            Some(b) => b,
166            None => return self.buffer.cursor_offset(),
167        };
168
169        let total_lines = self.buffer.len_lines();
170        if total_lines == 0 {
171            return 0;
172        }
173
174        if !self.visible_lines.is_empty() {
175            if pos.y < self.visible_lines[0].top {
176                return self.visible_lines[0].line_start_byte;
177            }
178
179            let last = self.visible_lines.last().unwrap();
180            if pos.y >= last.bottom {
181                return (last.line_start_byte + last.line_len_bytes).min(self.buffer.len_bytes());
182            }
183
184            let target_line = match find_visible_line(&self.visible_lines, pos.y) {
185                Some(l) => l,
186                None => last,
187            };
188
189            let row = target_line.row;
190            let line_start_byte = target_line.line_start_byte;
191            let task_state = target_line.task_state;
192            let text_origin_x = target_line.text_origin_x;
193            let line_top = target_line.top;
194            let raw_line = self.buffer.line_to_string(row);
195            let line_text = raw_line.trim_end_matches(['\r', '\n']);
196
197            if line_text.is_empty() || pos.x <= text_origin_x {
198                return line_start_byte;
199            }
200
201            let base_font_size = self.config.font_size;
202            let base_line_height = self.config.line_height;
203            let cursor_row = self.buffer.cursor_point().row;
204            let highlighter_rev = self.highlighter_rev;
205            let host_font = window.text_style().font();
206            let font = self.resolved_base_font(&host_font);
207            let code_font = self.resolved_code_font(&host_font);
208            let cached = self.layout_cache.cached_input(
209                &self.buffer,
210                self.highlighter.as_deref(),
211                highlighter_rev,
212                cursor_row,
213                row,
214                line_text,
215            );
216            let concealed = &cached.concealed;
217
218            // Mirror paint: headings shape at a scaled font size, so hit-test
219            // must use the same metrics or clicks drift on concealed lines.
220            let metrics = LineMetrics::for_line(
221                line_text,
222                &concealed.display_text,
223                &cached.spans,
224                base_font_size,
225                base_line_height,
226            );
227
228            let is_checked_task =
229                task_state == Some(true) && line_text.len() != concealed.display_text.len();
230
231            let fonts = RunFonts {
232                base: &font,
233                code: &code_font,
234            };
235            let runs = build_line_text_runs(
236                &concealed.display_text,
237                &concealed.spans,
238                None,
239                &fonts,
240                &self.theme,
241                metrics.is_code_block,
242                is_checked_task,
243            );
244
245            let wrap_width = if self.config.line_wrap && cached.allow_wrap {
246                let available = bounds.size.width - (text_origin_x - bounds.left()) - px(12.0);
247                Some(available.max(px(50.0)))
248            } else {
249                None
250            };
251
252            let text_line = window
253                .text_system()
254                .shape_text(
255                    concealed.display_text.clone().into(),
256                    metrics.font_size,
257                    &runs,
258                    wrap_width,
259                    None,
260                )
261                .ok()
262                .and_then(|mut l| l.pop())
263                .unwrap_or_default();
264
265            let line_rel_y = (pos.y - line_top).max(px(0.0));
266            let line_rel_x = (pos.x - text_origin_x).max(px(0.0));
267            let rel_pos = point(line_rel_x, line_rel_y);
268
269            let col_display = text_line
270                .closest_index_for_position(rel_pos, metrics.line_height)
271                .unwrap_or_else(|idx| idx);
272
273            let col_src = concealed.display_to_source(col_display);
274            return line_start_byte + col_src.min(line_text.len());
275        }
276
277        0
278    }
279
280    /// Returns the window pixel coordinates (X, Y) at the bottom of the active cursor.
281    ///
282    /// This value is automatically computed and cached during each canvas render pass.
283    /// Returns `None` if the editor has not yet been rendered, or if the cursor is scrolled
284    /// outside the visible viewport.
285    pub fn cursor_pixel_position(&self) -> Option<Point<Pixels>> {
286        self.last_cursor_pixel
287    }
288
289    /// Returns the target URL if `pos` is over a hyperlink.
290    pub fn link_at_position(&self, pos: Point<Pixels>) -> Option<String> {
291        let bounds = self.last_bounds?;
292        if !bounds.contains(&pos) {
293            return None;
294        }
295
296        if let Some(line) = find_visible_line(&self.visible_lines, pos.y) {
297            for link in &line.links {
298                if link.bounds.contains(&pos) {
299                    return Some(link.url.clone());
300                }
301            }
302        }
303
304        None
305    }
306
307    pub(crate) fn is_position_over_task_checkbox(
308        &self,
309        pos: Point<Pixels>,
310        _window: &Window,
311    ) -> bool {
312        let bounds = match self.last_bounds {
313            Some(b) => b,
314            None => return false,
315        };
316
317        if !bounds.contains(&pos) {
318            return false;
319        }
320
321        if let Some(line) = find_visible_line(&self.visible_lines, pos.y)
322            && line.is_task_checkbox
323        {
324            return pos.x >= line.checkbox_box_x && pos.x <= line.checkbox_box_x + px(22.0);
325        }
326
327        false
328    }
329}