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 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 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 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 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 pub fn cursor_pixel_position(&self) -> Option<Point<Pixels>> {
286 self.last_cursor_pixel
287 }
288
289 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}