1use crate::compare_cells;
6use crate::data::{CellValue, GridData};
7use crate::format::{cell_matches_filter, format_cell};
8use crate::grid::state::state_inner::apply_edge_scroll;
9use crate::grid::theme::GridTheme;
10
11use crate::config::{GridConfig, ResolvedColumnFormat};
12use gpui::{px, App, Bounds, FocusHandle, Keystroke, MouseButton, Pixels, Point, ScrollHandle};
13
14use crate::grid::menu as menu_mod;
16#[allow(unused_imports)]
17pub(crate) use crate::grid::menu::{ContextMenu, MenuAction, MenuItem};
18use crate::grid::selection::{
19 screen_to_content, HitResult, ScrollbarAxis, Selection, SortDirection,
20};
21
22pub mod state_inner {
26 use super::{
27 format_cell, CellValue, GridState, HitResult, Pixels, Point, ResolvedColumnFormat,
28 };
29 pub use crate::grid::selection::screen_to_content;
30 use std::fmt::Write as _;
31
32 pub fn edge_scroll_speed(dist_from_edge: f32) -> f32 {
35 if dist_from_edge > 150.0 {
36 return 0.0;
37 }
38 if dist_from_edge < 0.0 {
39 return (24.0 + (-dist_from_edge) * 0.6).min(80.0);
40 }
41 if dist_from_edge < 25.0 {
42 12.0
43 } else if dist_from_edge < 50.0 {
44 6.0
45 } else if dist_from_edge < 100.0 {
46 3.0
47 } else {
48 1.0
49 }
50 }
51
52 pub fn apply_edge_scroll(state: &mut GridState) -> bool {
53 if !state.is_dragging {
54 return false;
55 }
56 let Some(pos) = state.last_mouse_pos else {
57 return false;
58 };
59 let bounds = state.bounds;
60 let (x, y) = screen_to_content(pos, bounds.origin, state.scroll_handle.offset());
61 let vw: f32 = bounds.size.width.into();
62 let vh: f32 = bounds.size.height.into();
63 let right_dist = vw - x;
64 let left_dist = x - state.row_header_width;
65 let bottom_dist = vh - y;
66 let top_dist = y - state.header_height;
67 let mut dx = 0.0_f32;
68 let mut dy = 0.0_f32;
69 if right_dist < 150.0 && right_dist <= left_dist {
70 dx = edge_scroll_speed(right_dist);
71 } else if left_dist < 150.0 {
72 dx = -edge_scroll_speed(left_dist);
73 }
74 if bottom_dist < 150.0 && bottom_dist <= top_dist {
75 dy = edge_scroll_speed(bottom_dist);
76 } else if top_dist < 150.0 {
77 dy = -edge_scroll_speed(top_dist);
78 }
79 if dx == 0.0 && dy == 0.0 {
80 return false;
81 }
82 state.scroll_one_edge_tick(dx, dy);
83 if state.drag_start.is_some() {
84 state.update_drag_from_last();
85 }
86 true
87 }
88
89 #[must_use]
90 pub fn format_current_status(state: &GridState) -> String {
91 let scroll = state.scroll_handle.offset();
92 let (click_col, click_row) = col_row_from_hit(state.click_hit);
93 let (hover_col, hover_row) = col_row_from_hit(state.hover_hit);
94 let mut out = String::new();
95 let _ = write!(
96 out,
97 "Click: {} Scroll@Click: {} Cell: {} | Cur: {} Scroll: {} Over: {}",
98 fmt_point(state.click_pos),
99 fmt_point(state.scroll_at_click),
100 fmt_cr(click_col, click_row),
101 fmt_point(state.last_mouse_pos),
102 fmt_point(Some(scroll)),
103 fmt_cr(hover_col, hover_row),
104 );
105 out
106 }
107
108 fn col_row_from_hit(hit: Option<HitResult>) -> (Option<usize>, Option<usize>) {
109 match hit {
110 Some(HitResult::Cell(r, c)) => (Some(c), Some(r)),
111 Some(HitResult::RowHeader(r)) => (None, Some(r)),
112 Some(HitResult::ColumnHeader(c)) | Some(HitResult::SortButton(c)) => (Some(c), None),
113 _ => (None, None),
114 }
115 }
116
117 fn fmt_point(p: Option<Point<Pixels>>) -> String {
118 match p {
119 Some(p) => format!("({:.0}, {:.0})", f32::from(p.x), f32::from(p.y)),
120 None => "—".into(),
121 }
122 }
123
124 fn fmt_cr(c: Option<usize>, r: Option<usize>) -> String {
125 match (c, r) {
126 (Some(c), Some(r)) => format!("(col {c}, row {r})"),
127 (Some(c), None) => format!("(col {c})"),
128 (None, Some(r)) => format!("(row {r})"),
129 (None, None) => "—".into(),
130 }
131 }
132
133 #[must_use]
134 pub fn cell_text(cell: &CellValue, fmt: &ResolvedColumnFormat) -> String {
135 format_cell(cell, fmt).0
136 }
137}
138
139pub const SCROLLBAR_SIZE: f32 = 20.0;
141pub const EDGE_SCROLL_TICK_MS: u64 = 16;
143
144#[derive(Debug)]
146pub struct GridState {
147 pub data: GridData,
148 pub config: GridConfig,
149 pub resolved_formats: Vec<ResolvedColumnFormat>,
153 pub display_indices: Vec<usize>,
154 pub selection: Selection,
155 pub sort: Option<(usize, SortDirection)>,
156 pub filters: Vec<String>,
157 pub scroll_handle: ScrollHandle,
158 pub focus_handle: FocusHandle,
159 pub bounds: Bounds<Pixels>,
160 pub row_height: f32,
161 pub header_height: f32,
162 pub row_header_width: f32,
163 pub font_size: f32,
164 pub char_width: f32,
165 pub theme: GridTheme,
166 pub is_dragging: bool,
167 pub drag_start: Option<Point<Pixels>>,
168 pub drag_start_hit: Option<HitResult>,
169 pub scroll_at_click: Option<Point<Pixels>>,
170 pub last_mouse_pos: Option<Point<Pixels>>,
171 pub status_bar_height: f32,
172 pub click_pos: Option<Point<Pixels>>,
173 pub click_hit: Option<HitResult>,
174 pub hover_hit: Option<HitResult>,
175 pub resizing_col: Option<usize>,
176 pub resize_start_x: f32,
177 pub resize_start_width: f32,
178 pub context_menu: Option<ContextMenu>,
179 pub filter_prompt: Option<FilterPrompt>,
180 pub pending_action: Option<(MenuAction, usize)>,
181 pub scrollbar_drag: Option<ScrollbarAxis>,
182 pub scrollbar_drag_start_offset: f32,
183 pub scrollbar_drag_start_pos: f32,
184}
185
186#[derive(Clone, Debug)]
189pub struct FilterPrompt {
190 pub col: usize,
191 pub anchor: Point<Pixels>,
192 pub input: String,
193 pub cursor_chars: usize,
194}
195
196impl FilterPrompt {
197 fn new(col: usize, anchor: Point<Pixels>, input: String) -> Self {
198 let cursor_chars = input.chars().count();
199 Self {
200 col,
201 anchor,
202 input,
203 cursor_chars,
204 }
205 }
206
207 fn clamp_cursor(&mut self) {
208 let total = self.input.chars().count();
209 if self.cursor_chars > total {
210 self.cursor_chars = total;
211 }
212 }
213
214 fn insert_char(&mut self, ch: char) {
215 let byte_idx = byte_index_for_char(&self.input, self.cursor_chars);
216 self.input.insert(byte_idx, ch);
217 self.cursor_chars += 1;
218 }
219
220 fn backspace(&mut self) {
221 if self.cursor_chars == 0 {
222 return;
223 }
224 let end = byte_index_for_char(&self.input, self.cursor_chars);
225 let start = byte_index_for_char(&self.input, self.cursor_chars - 1);
226 self.input.replace_range(start..end, "");
227 self.cursor_chars -= 1;
228 }
229}
230
231fn byte_index_for_char(input: &str, char_idx: usize) -> usize {
232 input
233 .char_indices()
234 .nth(char_idx)
235 .map_or(input.len(), |(idx, _)| idx)
236}
237
238impl GridState {
239 #[must_use]
240 pub fn new(data: GridData, config: GridConfig, focus_handle: FocusHandle) -> Self {
241 let resolved_formats = config.resolve_all(&data.columns);
242 let col_count = data.columns.len();
243 let display_indices = (0..data.rows.len()).collect();
244 Self {
245 data,
246 config,
247 resolved_formats,
248 display_indices,
249 selection: Selection::None,
250 sort: None,
251 filters: vec![String::new(); col_count],
252 scroll_handle: ScrollHandle::new(),
253 focus_handle,
254 bounds: Bounds::default(),
255 row_height: 24.0,
256 header_height: 32.0,
257 row_header_width: 50.0,
258 font_size: 14.0,
259 char_width: 7.6,
260 theme: GridTheme::default(),
261 is_dragging: false,
262 drag_start: None,
263 drag_start_hit: None,
264 scroll_at_click: None,
265 last_mouse_pos: None,
266 status_bar_height: 24.0,
267 click_pos: None,
268 click_hit: None,
269 hover_hit: None,
270 resizing_col: None,
271 resize_start_x: 0.0,
272 resize_start_width: 0.0,
273 context_menu: None,
274 filter_prompt: None,
275 pending_action: None,
276 scrollbar_drag: None,
277 scrollbar_drag_start_offset: 0.0,
278 scrollbar_drag_start_pos: 0.0,
279 }
280 }
281
282 pub fn set_config(&mut self, config: GridConfig) {
283 self.config = config;
284 self.rebuild_resolved_formats();
285 self.recompute();
286 }
287
288 fn rebuild_resolved_formats(&mut self) {
289 self.resolved_formats = self.config.resolve_all(&self.data.columns);
290 }
291
292 pub fn recompute(&mut self) {
293 let mut indices: Vec<usize> = (0..self.data.rows.len())
294 .filter(|&row_idx| {
295 self.data.columns.iter().enumerate().all(|(col_idx, _col)| {
296 let filter = &self.filters[col_idx];
297 if filter.is_empty() {
298 return true;
299 }
300 let cell = &self.data.rows[row_idx][col_idx];
301 cell_matches_filter(cell, &self.resolved_formats[col_idx], filter)
302 })
303 })
304 .collect();
305
306 if let Some((sort_col, direction)) = self.sort {
307 indices.sort_by(|&a, &b| {
308 let cell_a = &self.data.rows[a][sort_col];
309 let cell_b = &self.data.rows[b][sort_col];
310 let ord = compare_cells(cell_a, cell_b);
311 match direction {
312 SortDirection::Ascending => ord,
313 SortDirection::Descending => ord.reverse(),
314 }
315 });
316 }
317 self.display_indices = indices;
318 }
319
320 fn content_size(&self) -> (f32, f32) {
321 let cw: f32 = self.data.columns.iter().map(|c| c.width).sum();
322 let ch = self.display_indices.len() as f32 * self.row_height;
323 (cw, ch)
324 }
325
326 pub(crate) fn max_scroll(&self) -> (f32, f32) {
327 let (cw, ch) = self.content_size();
328 let (rw, rh) = self.scrollbar_reserved();
329 let vw: f32 = self.bounds.size.width.into();
330 let vh: f32 = self.bounds.size.height.into();
331 let vw = vw - self.row_header_width - rw;
332 let vh = vh - self.header_height - rh;
333 ((cw - vw).max(0.0), (ch - vh).max(0.0))
334 }
335
336 fn scrollbar_reserved(&self) -> (f32, f32) {
337 let (cw, ch) = self.content_size();
338 let vw: f32 = self.bounds.size.width.into();
339 let vh: f32 = self.bounds.size.height.into();
340 let vw = vw - self.row_header_width;
341 let vh = vh - self.header_height;
342 let reserved_w = if ch > vh { SCROLLBAR_SIZE } else { 0.0 };
343 let reserved_h = if cw > vw { SCROLLBAR_SIZE } else { 0.0 };
344 (reserved_w, reserved_h)
345 }
346
347 fn vbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
348 let (_, ch) = self.content_size();
349 let (_, rh) = self.scrollbar_reserved();
350 let vh: f32 = self.bounds.size.height.into();
351 let vh = vh - self.header_height - rh;
352 if ch <= vh {
353 return None;
354 }
355 let ox: f32 = self.bounds.origin.x.into();
356 let oy: f32 = self.bounds.origin.y.into();
357 let sw: f32 = self.bounds.size.width.into();
358 let sh: f32 = self.bounds.size.height.into();
359 let track_x = ox + sw - SCROLLBAR_SIZE;
360 let track_y = oy + self.header_height;
361 let track_h = sh - self.header_height - rh;
362 let thumb_h = ((track_h * (vh / ch)).max(20.0)).min(track_h);
363 Some((track_x, track_y, SCROLLBAR_SIZE, track_h, thumb_h))
364 }
365
366 fn hbar_geom(&self) -> Option<(f32, f32, f32, f32, f32)> {
367 let (cw, _) = self.content_size();
368 let (rw, _) = self.scrollbar_reserved();
369 let vw: f32 = self.bounds.size.width.into();
370 let vw = vw - self.row_header_width - rw;
371 if cw <= vw {
372 return None;
373 }
374 let ox: f32 = self.bounds.origin.x.into();
375 let oy: f32 = self.bounds.origin.y.into();
376 let sw: f32 = self.bounds.size.width.into();
377 let sh: f32 = self.bounds.size.height.into();
378 let track_x = ox + self.row_header_width;
379 let track_y = oy + sh - SCROLLBAR_SIZE;
380 let track_w = sw - self.row_header_width - rw;
381 let thumb_w = ((track_w * (vw / cw)).max(20.0)).min(track_w);
382 Some((track_x, track_y, track_w, SCROLLBAR_SIZE, thumb_w))
383 }
384
385 pub(crate) fn scroll_to_vbar(&mut self, mouse_y: f32) {
386 if let Some((_, track_y, _, track_h, thumb_h)) = self.vbar_geom() {
387 let (_, max_y) = self.max_scroll();
388 let range = (track_h - thumb_h).max(0.0);
389 let rel = (mouse_y - track_y - thumb_h * 0.5).clamp(0.0, range);
390 let frac = if range > 0.0 { rel / range } else { 0.0 };
391 let new_y = frac * max_y;
392 let x = self.scroll_handle.offset().x;
393 self.scroll_handle.set_offset(Point { x, y: px(new_y) });
394 }
395 }
396
397 pub(crate) fn scroll_to_hbar(&mut self, mouse_x: f32) {
398 if let Some((track_x, _, track_w, _, thumb_w)) = self.hbar_geom() {
399 let (max_x, _) = self.max_scroll();
400 let range = (track_w - thumb_w).max(0.0);
401 let rel = (mouse_x - track_x - thumb_w * 0.5).clamp(0.0, range);
402 let frac = if range > 0.0 { rel / range } else { 0.0 };
403 let new_x = frac * max_x;
404 let y = self.scroll_handle.offset().y;
405 self.scroll_handle.set_offset(Point { x: px(new_x), y });
406 }
407 }
408
409 pub(crate) fn scroll_one_edge_tick(&mut self, dx: f32, dy: f32) {
410 let (mx, my) = self.max_scroll();
411 let s = self.scroll_handle.offset();
412 let new_x: f32 = (f32::from(s.x) + dx).clamp(0.0, mx);
413 let new_y: f32 = (f32::from(s.y) + dy).clamp(0.0, my);
414 self.scroll_handle.set_offset(Point {
415 x: px(new_x),
416 y: px(new_y),
417 });
418 }
419
420 pub fn toggle_sort(&mut self, col: usize) {
421 self.sort = match self.sort {
422 Some((c, SortDirection::Ascending)) if c == col => {
423 Some((col, SortDirection::Descending))
424 }
425 Some((c, SortDirection::Descending)) if c == col => None,
426 _ => Some((col, SortDirection::Ascending)),
427 };
428 self.recompute();
429 }
430
431 pub fn handle_mouse_down(&mut self, pos: Point<Pixels>, shift: bool) {
432 let hit = self.hit_test(pos);
433 self.click_pos = Some(pos);
434 self.click_hit = Some(hit);
435 match hit {
436 HitResult::VerticalScrollbar => {
437 self.scrollbar_drag = Some(ScrollbarAxis::Vertical);
438 self.scroll_to_vbar(f32::from(pos.y));
439 self.clear_drag();
440 }
441 HitResult::HorizontalScrollbar => {
442 self.scrollbar_drag = Some(ScrollbarAxis::Horizontal);
443 self.scroll_to_hbar(f32::from(pos.x));
444 self.clear_drag();
445 }
446 HitResult::ColumnBorder(col) => {
447 self.resizing_col = Some(col);
448 self.resize_start_x = f32::from(pos.x);
449 self.resize_start_width = self.data.columns[col].width;
450 self.clear_drag();
451 }
452 HitResult::ColumnHeader(col) => {
453 self.selection = Selection::Column(col);
454 self.clear_drag();
455 }
456 HitResult::SortButton(col) => {
457 self.selection = Selection::Column(col);
458 self.toggle_sort(col);
459 self.clear_drag();
460 }
461 HitResult::ContextMenuItem(_) => {}
462 HitResult::RowHeader(row) => {
463 self.selection = if shift {
464 if let Selection::Row(prev) = self.selection {
465 let (s, e) = (prev, row);
466 Selection::RowRange(s.min(e), s.max(e))
467 } else {
468 Selection::Row(row)
469 }
470 } else {
471 Selection::Row(row)
472 };
473 self.start_drag(pos);
474 self.drag_start_hit = Some(HitResult::RowHeader(row));
475 }
476 HitResult::Cell(row, col) => {
477 self.selection = if shift {
478 if let Selection::Cell(pr, pc) = self.selection {
479 Selection::CellRange(pr.min(row), pc.min(col), pr.max(row), pc.max(col))
480 } else {
481 Selection::Cell(row, col)
482 }
483 } else {
484 Selection::Cell(row, col)
485 };
486 self.start_drag(pos);
487 self.drag_start_hit = Some(HitResult::Cell(row, col));
488 }
489 HitResult::Corner | HitResult::None => {
490 self.selection = Selection::None;
491 self.context_menu = None;
492 self.filter_prompt = None;
493 self.clear_drag();
494 }
495 }
496 }
497
498 fn start_drag(&mut self, pos: Point<Pixels>) {
499 self.is_dragging = false;
500 self.drag_start = Some(pos);
501 self.scroll_at_click = Some(self.scroll_handle.offset());
502 self.last_mouse_pos = Some(pos);
503 }
504
505 pub(crate) fn open_context_menu(&mut self, col: usize, anchor: Point<Pixels>) {
506 self.context_menu = Some(menu_mod::ContextMenu::standard(col, anchor));
507 self.filter_prompt = None;
508 }
509
510 pub fn execute_action(&mut self, action: MenuAction, col: usize, cx: &mut App) {
511 match action {
512 MenuAction::SelectColumn => {
513 self.selection = Selection::Column(col);
514 }
515 MenuAction::CopyColumn => {
516 let text = self.column_text(col);
517 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
518 }
519 MenuAction::CopyColumnWithHeaders => {
520 let mut text = String::new();
521 text.push_str(&self.data.columns[col].name);
522 text.push('\n');
523 text.push_str(&self.column_text(col));
524 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
525 }
526 MenuAction::SortAscending => {
527 self.sort = Some((col, SortDirection::Ascending));
528 self.recompute();
529 }
530 MenuAction::SortDescending => {
531 self.sort = Some((col, SortDirection::Descending));
532 self.recompute();
533 }
534 MenuAction::ClearSort => {
535 self.sort = None;
536 self.recompute();
537 }
538 MenuAction::FilterPrompt => {
539 let anchor = self.last_mouse_pos.unwrap_or(Point {
540 x: px(0.0),
541 y: px(0.0),
542 });
543 let existing = self.filters.get(col).cloned().unwrap_or_default();
544 self.filter_prompt = Some(FilterPrompt::new(col, anchor, existing));
545 }
546 MenuAction::ClearFilter => {
547 if col < self.filters.len() {
548 self.filters[col].clear();
549 self.recompute();
550 }
551 }
552 }
553 self.context_menu = None;
554 }
555
556 fn column_text(&self, col: usize) -> String {
557 let mut text = String::new();
558 let fmt = &self.resolved_formats[col];
559 for &row_idx in &self.display_indices {
560 let cell = &self.data.rows[row_idx][col];
561 let (s, _) = format_cell(cell, fmt);
562 text.push_str(&s);
563 text.push('\n');
564 }
565 text
566 }
567
568 fn clear_drag(&mut self) {
569 self.is_dragging = false;
570 self.drag_start = None;
571 self.drag_start_hit = None;
572 self.scroll_at_click = None;
573 }
574
575 fn drag_world_corners(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
576 let start = self.drag_start?;
577 let mouse = self.last_mouse_pos?;
578 let click_scroll = self
579 .scroll_at_click
580 .unwrap_or_else(|| self.scroll_handle.offset());
581 let scroll = self.scroll_handle.offset();
582 let sx_click: f32 = click_scroll.x.into();
583 let sy_click: f32 = click_scroll.y.into();
584 let sx: f32 = scroll.x.into();
585 let sy: f32 = scroll.y.into();
586 let sx0: f32 = start.x.into();
587 let sy0: f32 = start.y.into();
588 let mx: f32 = mouse.x.into();
589 let my: f32 = mouse.y.into();
590 let start_world = Point {
591 x: px(sx0 + sx_click),
592 y: px(sy0 + sy_click),
593 };
594 let end_world = Point {
595 x: px(mx + sx),
596 y: px(my + sy),
597 };
598 Some((start_world, end_world))
599 }
600
601 pub fn drag_screen_rect(&self) -> Option<(Point<Pixels>, Point<Pixels>)> {
602 if !self.is_dragging {
603 return None;
604 }
605 let (start_world, end_world) = self.drag_world_corners()?;
606 let scroll = self.scroll_handle.offset();
607 let sx: f32 = scroll.x.into();
608 let sy: f32 = scroll.y.into();
609 let start_screen = Point {
610 x: px(f32::from(start_world.x) - sx),
611 y: px(f32::from(start_world.y) - sy),
612 };
613 let end_screen = Point {
614 x: px(f32::from(end_world.x) - sx),
615 y: px(f32::from(end_world.y) - sy),
616 };
617 Some((start_screen, end_screen))
618 }
619
620 fn update_drag(&mut self) {
621 let (start_world, end_world) = match self.drag_world_corners() {
622 Some(c) => c,
623 None => return,
624 };
625 if !self.is_dragging {
626 let dx = f32::from(end_world.x) - f32::from(start_world.x);
627 let dy = f32::from(end_world.y) - f32::from(start_world.y);
628 if dx * dx + dy * dy <= 400.0 {
629 return;
630 }
631 self.is_dragging = true;
632 }
633 let r1 = match self.drag_start_hit {
634 Some(h) => h,
635 None => return,
636 };
637 let ox: f32 = self.bounds.origin.x.into();
638 let oy: f32 = self.bounds.origin.y.into();
639 let r2 = self.hit_test_content(
640 f32::from(end_world.x) - ox,
641 f32::from(end_world.y) - oy,
642 0.0,
643 0.0,
644 );
645 match (r1, r2) {
646 (HitResult::Cell(r1c, c1), HitResult::Cell(r2c, c2)) => {
647 self.selection =
648 Selection::CellRange(r1c.min(r2c), c1.min(c2), r1c.max(r2c), c1.max(c2));
649 }
650 (HitResult::RowHeader(r1r), HitResult::RowHeader(r2r)) => {
651 self.selection = Selection::RowRange(r1r.min(r2r), r1r.max(r2r));
652 }
653 _ => {}
654 }
655 }
656
657 fn update_drag_from_last(&mut self) {
658 self.update_drag();
659 }
660
661 pub fn handle_mouse_move(&mut self, pos: Point<Pixels>, pressed_button: Option<MouseButton>) {
662 if self.is_dragging && pressed_button != Some(MouseButton::Left) {
663 self.handle_mouse_up();
664 return;
665 }
666 if let Some(col) = self.resizing_col {
667 if pressed_button != Some(MouseButton::Left) {
668 self.resizing_col = None;
669 return;
670 }
671 let new_w =
672 (self.resize_start_width + (f32::from(pos.x) - self.resize_start_x)).max(40.0);
673 self.data.columns[col].width = new_w;
674 return;
675 }
676 if let Some(axis) = self.scrollbar_drag {
677 if pressed_button != Some(MouseButton::Left) {
678 self.scrollbar_drag = None;
679 return;
680 }
681 match axis {
682 ScrollbarAxis::Vertical => self.scroll_to_vbar(f32::from(pos.y)),
683 ScrollbarAxis::Horizontal => self.scroll_to_hbar(f32::from(pos.x)),
684 }
685 self.last_mouse_pos = Some(pos);
686 return;
687 }
688 self.last_mouse_pos = Some(pos);
689 if let Some(menu) = self.context_menu.clone() {
690 let cw = self.char_width;
691 let (x_rel, y_rel) =
692 screen_to_content(pos, self.bounds.origin, self.scroll_handle.offset());
693 let hovered = menu_mod::hover_at(&menu, x_rel, y_rel, cw);
694 if let Some(menu_mut) = self.context_menu.as_mut() {
695 menu_mut.hovered = hovered;
696 }
697 self.hover_hit = Some(self.hit_test(pos));
698 return;
699 }
700 self.hover_hit = Some(self.hit_test(pos));
701 if self.drag_start.is_none() {
702 return;
703 }
704 self.update_drag();
705 }
706
707 pub fn handle_scroll_drag(&mut self) {
708 if self.drag_start.is_some() && self.last_mouse_pos.is_some() {
709 self.update_drag();
710 }
711 }
712
713 pub fn handle_mouse_up(&mut self) {
714 self.resizing_col = None;
715 self.scrollbar_drag = None;
716 self.clear_drag();
717 }
718
719 pub fn apply_edge_scroll(&mut self) -> bool {
720 apply_edge_scroll(self)
721 }
722
723 pub fn select_all(&mut self) {
724 let nrows = self.display_indices.len();
725 let ncols = self.data.columns.len();
726 if nrows > 0 && ncols > 0 {
727 self.selection = Selection::CellRange(0, 0, nrows - 1, ncols - 1);
728 }
729 }
730
731 pub fn copy_selection(&self, with_headers: bool, cx: &mut App) {
732 let Some((raw_r1, raw_c1, raw_r2, raw_c2)) = self.selection.normalized_bounds() else {
733 return;
734 };
735 if self.display_indices.is_empty() || self.data.columns.is_empty() {
736 return;
737 }
738 let last_row = self.display_indices.len() - 1;
739 let last_col = self.data.columns.len() - 1;
740 let r1 = raw_r1.min(last_row);
741 let r2 = raw_r2.min(last_row);
742 let c1 = raw_c1.min(last_col);
743 let c2 = raw_c2.min(last_col);
744 let mut text = String::new();
745 if with_headers {
746 for c in c1..=c2 {
747 if c > c1 {
748 text.push('\t');
749 }
750 text.push_str(&self.data.columns[c].name);
751 }
752 text.push('\n');
753 }
754 for dr in r1..=r2 {
755 let row_idx = self.display_indices[dr];
756 for c in c1..=c2 {
757 if c > c1 {
758 text.push('\t');
759 }
760 let cell = &self.data.rows[row_idx][c];
761 let (s, _) = format_cell(cell, &self.resolved_formats[c]);
762 text.push_str(&s);
763 }
764 text.push('\n');
765 }
766 cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
767 }
768
769 pub fn page_up(&mut self) {
770 let vh: f32 = self.bounds.size.height.into();
771 let rows = ((vh - self.header_height) / self.row_height) as i32;
772 self.move_selection(0, -rows);
773 }
774
775 pub fn page_down(&mut self) {
776 let vh: f32 = self.bounds.size.height.into();
777 let rows = ((vh - self.header_height) / self.row_height) as i32;
778 self.move_selection(0, rows);
779 }
780
781 pub fn handle_key(&mut self, keystroke: &Keystroke) {
782 if let Some(prompt) = &mut self.filter_prompt {
783 match keystroke.key.as_str() {
784 "escape" => self.filter_prompt = None,
785 "enter" => {
786 let col = prompt.col;
787 self.filters[col] = prompt.input.clone();
788 self.filter_prompt = None;
789 self.recompute();
790 }
791 "backspace" => prompt.backspace(),
792 "left" => {
793 if prompt.cursor_chars > 0 {
794 prompt.cursor_chars -= 1;
795 }
796 }
797 "right" => {
798 prompt.clamp_cursor();
799 if prompt.cursor_chars < prompt.input.chars().count() {
800 prompt.cursor_chars += 1;
801 }
802 }
803 _ => {
804 if let Some(ch) = keystroke_to_char(keystroke) {
805 prompt.insert_char(ch);
806 }
807 }
808 }
809 return;
810 }
811 if self.context_menu.is_some() {
812 if keystroke.key.as_str() == "escape" {
813 self.context_menu = None;
814 }
815 return;
816 }
817 match keystroke.key.as_str() {
818 "up" => self.move_selection(0, -1),
819 "down" => self.move_selection(0, 1),
820 "left" => self.move_selection(-1, 0),
821 "right" => self.move_selection(1, 0),
822 "escape" => self.selection = Selection::None,
823 _ => {}
824 }
825 }
826
827 fn move_selection(&mut self, dx: i32, dy: i32) {
828 let nrows = self.display_indices.len() as i32;
829 let ncols = self.data.columns.len() as i32;
830 if nrows == 0 || ncols == 0 {
831 return;
832 }
833 let last_row = nrows - 1;
834 let last_col = ncols - 1;
835 match self.selection {
836 Selection::Cell(row, col) => {
837 let nr = (row as i32 + dy).clamp(0, last_row) as usize;
838 let nc = (col as i32 + dx).clamp(0, last_col) as usize;
839 self.selection = Selection::Cell(nr, nc);
840 }
841 Selection::Row(row) if dy != 0 => {
842 let nr = (row as i32 + dy).clamp(0, last_row) as usize;
843 self.selection = Selection::Row(nr);
844 }
845 Selection::Column(col) if dx != 0 => {
846 let nc = (col as i32 + dx).clamp(0, last_col) as usize;
847 self.selection = Selection::Column(nc);
848 }
849 _ => self.selection = Selection::Cell(0, 0),
850 }
851 }
852
853 pub(crate) fn hit_test(&self, pos: Point<Pixels>) -> HitResult {
854 let bounds = self.bounds;
855 let (sx, sy) = (
856 f32::from(self.scroll_handle.offset().x),
857 f32::from(self.scroll_handle.offset().y),
858 );
859 let bw: f32 = bounds.size.width.into();
860 let bh: f32 = bounds.size.height.into();
861 let (mx, my) = self.max_scroll();
862 if let Some(menu) = &self.context_menu {
863 let cw = self.char_width;
864 let (x_rel, y_rel) = screen_to_content(pos, bounds.origin, self.scroll_handle.offset());
865 if let Some(idx) = menu_mod::hover_at(menu, x_rel, y_rel, cw) {
866 return HitResult::ContextMenuItem(idx);
867 }
868 }
869 if my > 0.0
870 && f32::from(pos.x) >= bw - SCROLLBAR_SIZE
871 && f32::from(pos.y) >= self.header_height
872 {
873 return HitResult::VerticalScrollbar;
874 }
875 if mx > 0.0
876 && f32::from(pos.y) >= bh - SCROLLBAR_SIZE
877 && f32::from(pos.x) >= self.row_header_width
878 {
879 return HitResult::HorizontalScrollbar;
880 }
881 let (cx, cy) = screen_to_content(pos, bounds.origin, self.scroll_handle.offset());
882 if cx < 0.0 || cy < 0.0 || cx > bw || cy > bh {
883 return HitResult::None;
884 }
885 self.hit_test_content(cx, cy, sx, sy)
886 }
887
888 fn hit_test_content(&self, x: f32, y: f32, sx: f32, sy: f32) -> HitResult {
889 if y < self.header_height {
890 if x < self.row_header_width {
891 return HitResult::Corner;
892 }
893 let col_x = x - self.row_header_width + sx;
894 let mut acc = 0.0;
895 for (i, col) in self.data.columns.iter().enumerate() {
896 let right = acc + col.width;
897 if i + 1 < self.data.columns.len() && col_x >= right - 5.0 && col_x <= right + 5.0 {
898 return HitResult::ColumnBorder(i);
899 }
900 if col_x >= acc && col_x < right {
901 if col_x >= right - 20.0 {
902 return HitResult::SortButton(i);
903 }
904 return HitResult::ColumnHeader(i);
905 }
906 acc = right;
907 }
908 return HitResult::None;
909 }
910 if x < self.row_header_width {
911 let row_y = y - self.header_height + sy;
912 if row_y < 0.0 {
913 return HitResult::None;
914 }
915 let row_idx = (row_y / self.row_height) as usize;
916 if row_idx < self.display_indices.len() {
917 return HitResult::RowHeader(row_idx);
918 }
919 return HitResult::None;
920 }
921 let col_x = x - self.row_header_width + sx;
922 let row_y = y - self.header_height + sy;
923 if row_y < 0.0 {
924 return HitResult::None;
925 }
926 let row_idx = (row_y / self.row_height) as usize;
927 if row_idx >= self.display_indices.len() {
928 return HitResult::None;
929 }
930 let mut acc = 0.0;
931 for (i, col) in self.data.columns.iter().enumerate() {
932 if col_x >= acc && col_x < acc + col.width {
933 return HitResult::Cell(row_idx, i);
934 }
935 acc += col.width;
936 }
937 HitResult::None
938 }
939
940 #[must_use]
941 pub fn wants_edge_scroll_tick(&self) -> bool {
942 self.is_dragging
943 }
944}
945
946fn keystroke_to_char(k: &Keystroke) -> Option<char> {
947 if k.modifiers.control || k.modifiers.platform || k.modifiers.alt {
948 return None;
949 }
950 if let Some(key_char) = k.key_char.as_ref() {
951 return key_char.chars().next();
952 }
953 if k.key.chars().count() == 1 {
954 let c = k.key.chars().next()?;
955 if k.modifiers.shift {
956 Some(c.to_ascii_uppercase())
957 } else {
958 Some(c)
959 }
960 } else {
961 None
962 }
963}
964
965#[cfg(test)]
966#[allow(
967 clippy::unwrap_used,
968 clippy::expect_used,
969 clippy::field_reassign_with_default
970)]
971mod tests {
972 use super::*;
973 use crate::data::{CellValue, Column, ColumnKind};
974 use crate::grid::state::state_inner::{edge_scroll_speed, format_current_status};
975
976 fn anchor() -> Point<Pixels> {
977 Point {
978 x: px(0.0),
979 y: px(0.0),
980 }
981 }
982
983 fn prompt_with(text: &str, cursor: usize) -> FilterPrompt {
984 let mut p = FilterPrompt::new(0, anchor(), text.to_owned());
985 p.cursor_chars = cursor;
986 p
987 }
988
989 #[test]
990 fn filter_prompt_new_cursors_at_char_count_not_bytes() {
991 let p = FilterPrompt::new(0, anchor(), "hé🙂".into());
993 assert_eq!(p.cursor_chars, 3);
994 assert_eq!(p.input.len(), 7);
995 }
996
997 #[test]
998 fn filter_prompt_insert_emoji_at_start_does_not_panic() {
999 let mut p = prompt_with("ab", 0);
1000 p.insert_char('\u{1F600}');
1001 assert_eq!(p.input, "\u{1F600}ab");
1002 assert_eq!(p.cursor_chars, 1);
1003 }
1004
1005 #[test]
1006 fn filter_prompt_insert_in_middle_keeps_cursor_at_char_position() {
1007 let mut p = prompt_with("helloworld", 5);
1008 p.insert_char(' ');
1009 assert_eq!(p.input, "hello world");
1010 assert_eq!(p.cursor_chars, 6);
1011 }
1012
1013 #[test]
1014 fn filter_prompt_backspace_at_zero_is_noop() {
1015 let mut p = prompt_with("abc", 0);
1016 p.backspace();
1017 assert_eq!(p.input, "abc");
1018 assert_eq!(p.cursor_chars, 0);
1019 }
1020
1021 #[test]
1022 fn filter_prompt_backspace_removes_one_char_value() {
1023 let mut p = prompt_with("héx", 2);
1025 p.backspace();
1026 assert_eq!(p.input, "hx");
1027 assert_eq!(p.cursor_chars, 1);
1028 }
1029
1030 #[test]
1031 fn filter_prompt_clamp_cursor_pulls_back_past_end() {
1032 let mut p = prompt_with("abc", 99);
1033 p.clamp_cursor();
1034 assert_eq!(p.cursor_chars, 3);
1035 }
1036
1037 #[test]
1038 fn edge_scroll_speed_stops_outside_band() {
1039 assert_eq!(edge_scroll_speed(200.0), 0.0);
1040 assert_eq!(edge_scroll_speed(-100.0), 80.0); assert_eq!(edge_scroll_speed(0.0), 12.0); assert_eq!(edge_scroll_speed(24.99), 12.0);
1043 assert_eq!(edge_scroll_speed(25.0), 6.0); assert_eq!(edge_scroll_speed(49.99), 6.0);
1045 assert_eq!(edge_scroll_speed(50.0), 3.0); assert_eq!(edge_scroll_speed(99.99), 3.0);
1047 assert_eq!(edge_scroll_speed(100.0), 1.0); assert_eq!(edge_scroll_speed(149.99), 1.0);
1049 }
1050
1051 #[test]
1052 fn edge_scroll_speed_caps_negative_runaway() {
1053 assert_eq!(edge_scroll_speed(-1000.0), 80.0);
1055 }
1056
1057 #[allow(clippy::expect_used, clippy::unwrap_used)]
1065 #[test]
1066 #[ignore = "requires gpui::Application which must run on the OS main thread; can only be executed under a custom main harness"]
1067 fn grid_state_behavior_under_application() {
1068 gpui::Application::new().run(|cx| {
1069 let focus = cx.focus_handle();
1070
1071 let mut state = GridState::new(
1073 GridData::new(
1074 vec![Column::new("n", ColumnKind::Integer, 100.0)],
1075 vec![vec![CellValue::Integer(1)]],
1076 )
1077 .expect("rectangular"),
1078 crate::config::GridConfig::default(),
1079 focus.clone(),
1080 );
1081 let _ = format_current_status(&state);
1082 assert_eq!(state.selection, Selection::None);
1083
1084 state.last_mouse_pos = Some(Point {
1086 x: px(120.0),
1087 y: px(80.0),
1088 });
1089 let s = format_current_status(&state);
1090 assert!(s.contains("(120, 80)"), "missing positional, got: {s}");
1091
1092 let mut state = GridState::new(
1094 GridData::new(
1095 vec![Column::new("name", ColumnKind::Text, 100.0)],
1096 vec![
1097 vec![CellValue::Text("alpha".into())],
1098 vec![CellValue::Text("beta".into())],
1099 vec![CellValue::Text("gamma".into())],
1100 ],
1101 )
1102 .expect("rectangular"),
1103 crate::config::GridConfig::default(),
1104 focus.clone(),
1105 );
1106 state.filters[0] = "a".into();
1107 state.toggle_sort(0);
1108 state.recompute();
1109 assert_eq!(state.display_indices, vec![0, 2]);
1110 state.toggle_sort(0);
1111 state.recompute();
1112 assert_eq!(state.display_indices, vec![2, 0]);
1113 state.filters[0].clear();
1114 state.toggle_sort(0);
1115 state.recompute();
1116 assert_eq!(state.display_indices, vec![0, 1, 2]);
1117
1118 let mut state = GridState::new(
1120 GridData::new(
1121 vec![Column::new("v", ColumnKind::Integer, 80.0)],
1122 vec![vec![CellValue::Integer(1)]],
1123 )
1124 .expect("rectangular"),
1125 crate::config::GridConfig::default(),
1126 focus.clone(),
1127 );
1128 state.toggle_sort(0);
1129 assert_eq!(state.sort, Some((0, SortDirection::Ascending)));
1130 state.toggle_sort(0);
1131 assert_eq!(state.sort, Some((0, SortDirection::Descending)));
1132 state.toggle_sort(0);
1133 assert_eq!(state.sort, None);
1134
1135 let mut state = GridState::new(
1137 GridData::new(
1138 vec![
1139 Column::new("a", ColumnKind::Integer, 80.0),
1140 Column::new("b", ColumnKind::Integer, 80.0),
1141 ],
1142 vec![vec![CellValue::Integer(1), CellValue::Integer(2)]],
1143 )
1144 .expect("rectangular"),
1145 crate::config::GridConfig::default(),
1146 focus.clone(),
1147 );
1148 state.select_all();
1149 assert_eq!(state.selection, Selection::CellRange(0, 0, 0, 1));
1150
1151 let mut state = GridState::new(
1153 GridData::new(vec![Column::new("a", ColumnKind::Integer, 80.0)], vec![])
1154 .expect("rectangular"),
1155 crate::config::GridConfig::default(),
1156 focus.clone(),
1157 );
1158 state.select_all();
1159 assert_eq!(state.selection, Selection::None);
1160
1161 let mut state = GridState::new(
1163 GridData::new(
1164 vec![Column::new("v", ColumnKind::Decimal, 100.0)],
1165 vec![vec![CellValue::Decimal(1.234)]],
1166 )
1167 .expect("rectangular"),
1168 crate::config::GridConfig::default(),
1169 focus.clone(),
1170 );
1171 assert_eq!(state.resolved_formats[0].number.decimals, 2);
1172 let mut cfg = crate::config::GridConfig::default();
1173 cfg.column_overrides = vec![crate::config::ColumnOverride {
1174 number: Some(crate::config::NumberFormat {
1175 decimals: 6,
1176 ..Default::default()
1177 }),
1178 ..Default::default()
1179 }];
1180 state.set_config(cfg);
1181 assert_eq!(state.resolved_formats[0].number.decimals, 6);
1182
1183 let mut state = GridState::new(
1185 GridData::new(
1186 vec![Column::new("a", ColumnKind::Integer, 80.0)],
1187 vec![vec![CellValue::Integer(1)]],
1188 )
1189 .expect("rectangular"),
1190 crate::config::GridConfig::default(),
1191 focus.clone(),
1192 );
1193 assert!(!state.wants_edge_scroll_tick());
1194 state.is_dragging = true;
1195 assert!(state.wants_edge_scroll_tick());
1196
1197 cx.quit();
1198 });
1199 }
1200}