twrite_gpui/editor/
geometry.rs1use 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
8pub(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 pub fn scroll_up(&mut self, count: usize) {
23 self.scroll_row = self.scroll_row.saturating_sub(count);
24 }
25
26 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 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 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 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 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 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 pub fn cursor_pixel_position(&self) -> Option<Point<Pixels>> {
358 self.last_cursor_pixel
359 }
360
361 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}