ghostscope_ui/components/source_panel/
navigation.rs1use crate::action::Action;
2use crate::model::panel_state::{SourcePanelMode, SourcePanelState};
3
4pub struct SourceNavigation;
6
7impl SourceNavigation {
8 pub fn move_up(state: &mut SourcePanelState) -> Vec<Action> {
10 if state.cursor_line > 0 {
11 state.cursor_line -= 1;
12 Self::ensure_column_bounds(state);
13 }
14 Vec::new()
15 }
16
17 pub fn move_down(state: &mut SourcePanelState) -> Vec<Action> {
19 if state.cursor_line < state.content.len().saturating_sub(1) {
20 state.cursor_line += 1;
21 Self::ensure_column_bounds(state);
22 }
23 Vec::new()
24 }
25
26 pub fn move_left(state: &mut SourcePanelState) -> Vec<Action> {
28 if state.cursor_col > 0 {
29 state.cursor_col -= 1;
30 } else if state.cursor_line > 0 {
31 state.cursor_line -= 1;
33 if let Some(prev_line_content) = state.content.get(state.cursor_line) {
34 state.cursor_col = if prev_line_content.is_empty() {
36 0
37 } else {
38 prev_line_content.chars().count().saturating_sub(1)
39 };
40 }
41 }
42 Self::ensure_column_bounds(state);
43 Vec::new()
44 }
45
46 pub fn move_right(state: &mut SourcePanelState) -> Vec<Action> {
48 if let Some(current_line_content) = state.content.get(state.cursor_line) {
49 let max_column = if current_line_content.is_empty() {
50 0
51 } else {
52 current_line_content.chars().count().saturating_sub(1)
53 };
54
55 if state.cursor_col < max_column {
56 state.cursor_col += 1;
57 } else if state.cursor_line < state.content.len().saturating_sub(1) {
58 state.cursor_line += 1;
60 state.cursor_col = 0;
61 }
62 }
63 Self::ensure_column_bounds(state);
64 Vec::new()
65 }
66
67 pub fn move_up_fast(state: &mut SourcePanelState) -> Vec<Action> {
69 let page_size = 10; state.cursor_line = state.cursor_line.saturating_sub(page_size);
72 Self::ensure_column_bounds(state);
73 Vec::new()
74 }
75
76 pub fn move_down_fast(state: &mut SourcePanelState) -> Vec<Action> {
78 let page_size = 10; state.cursor_line =
81 (state.cursor_line + page_size).min(state.content.len().saturating_sub(1));
82 Self::ensure_column_bounds(state);
83 Vec::new()
84 }
85
86 pub fn move_half_page_up(state: &mut SourcePanelState) -> Vec<Action> {
88 state.cursor_line = state.cursor_line.saturating_sub(10);
89 Self::ensure_column_bounds(state);
90 Vec::new()
91 }
92
93 pub fn move_half_page_down(state: &mut SourcePanelState) -> Vec<Action> {
95 state.cursor_line = (state.cursor_line + 10).min(state.content.len().saturating_sub(1));
96 Self::ensure_column_bounds(state);
97 Vec::new()
98 }
99
100 pub fn move_to_top(state: &mut SourcePanelState) -> Vec<Action> {
102 state.cursor_line = 0;
103 state.cursor_col = 0;
104 state.scroll_offset = 0;
105 state.horizontal_scroll_offset = 0;
106 Vec::new()
107 }
108
109 pub fn move_to_bottom(state: &mut SourcePanelState) -> Vec<Action> {
111 state.cursor_line = state.content.len().saturating_sub(1);
112 state.cursor_col = 0;
113 Vec::new()
114 }
115
116 pub fn move_word_forward(state: &mut SourcePanelState) -> Vec<Action> {
118 if let Some(current_line) = state.content.get(state.cursor_line) {
119 let chars: Vec<char> = current_line.chars().collect();
120 let mut pos = state.cursor_col;
121
122 if pos >= chars.len() {
123 if state.cursor_line < state.content.len().saturating_sub(1) {
125 state.cursor_line += 1;
126 state.cursor_col = 0;
127 if let Some(next_line) = state.content.get(state.cursor_line) {
129 let next_chars: Vec<char> = next_line.chars().collect();
130 let mut next_pos = 0;
131 while next_pos < next_chars.len() && next_chars[next_pos].is_whitespace() {
132 next_pos += 1;
133 }
134 state.cursor_col = next_pos;
135 }
136 }
137 } else {
138 let current_char = chars[pos];
140
141 if current_char.is_whitespace() {
142 while pos < chars.len() && chars[pos].is_whitespace() {
144 pos += 1;
145 }
146 } else if current_char.is_alphanumeric() || current_char == '_' {
147 while pos < chars.len() && (chars[pos].is_alphanumeric() || chars[pos] == '_') {
149 pos += 1;
150 }
151 while pos < chars.len() && chars[pos].is_whitespace() {
153 pos += 1;
154 }
155 } else {
156 while pos < chars.len()
158 && !chars[pos].is_whitespace()
159 && !chars[pos].is_alphanumeric()
160 && chars[pos] != '_'
161 {
162 pos += 1;
163 }
164 while pos < chars.len() && chars[pos].is_whitespace() {
166 pos += 1;
167 }
168 }
169
170 if pos >= chars.len() {
172 if state.cursor_line < state.content.len().saturating_sub(1) {
173 state.cursor_line += 1;
174 state.cursor_col = 0;
175 if let Some(next_line) = state.content.get(state.cursor_line) {
177 let next_chars: Vec<char> = next_line.chars().collect();
178 let mut next_pos = 0;
179 while next_pos < next_chars.len()
180 && next_chars[next_pos].is_whitespace()
181 {
182 next_pos += 1;
183 }
184 state.cursor_col = next_pos;
185 }
186 } else {
187 state.cursor_col = chars.len().saturating_sub(1).max(0);
189 }
190 } else {
191 state.cursor_col = pos;
192 }
193 }
194 }
195
196 Self::ensure_column_bounds(state);
197 Vec::new()
198 }
199
200 pub fn move_word_backward(state: &mut SourcePanelState) -> Vec<Action> {
202 if state.cursor_col == 0 {
203 if state.cursor_line > 0 {
205 state.cursor_line -= 1;
206 if let Some(prev_line) = state.content.get(state.cursor_line) {
207 if prev_line.is_empty() {
208 state.cursor_col = 0;
209 } else {
210 let chars: Vec<char> = prev_line.chars().collect();
212 let mut pos = chars.len().saturating_sub(1);
213
214 while pos > 0 && chars[pos].is_whitespace() {
216 pos = pos.saturating_sub(1);
217 }
218
219 if chars[pos].is_alphanumeric() || chars[pos] == '_' {
221 while pos > 0
222 && (chars[pos.saturating_sub(1)].is_alphanumeric()
223 || chars[pos.saturating_sub(1)] == '_')
224 {
225 pos = pos.saturating_sub(1);
226 }
227 } else {
228 while pos > 0
230 && !chars[pos.saturating_sub(1)].is_whitespace()
231 && !chars[pos.saturating_sub(1)].is_alphanumeric()
232 && chars[pos.saturating_sub(1)] != '_'
233 {
234 pos = pos.saturating_sub(1);
235 }
236 }
237
238 state.cursor_col = pos;
239 }
240 }
241 }
242 } else if let Some(current_line) = state.content.get(state.cursor_line) {
243 let chars: Vec<char> = current_line.chars().collect();
244 let mut pos = state.cursor_col;
245
246 pos = pos.saturating_sub(1);
248
249 while pos > 0 && chars[pos].is_whitespace() {
251 pos = pos.saturating_sub(1);
252 }
253
254 if pos < chars.len() {
256 if chars[pos].is_alphanumeric() || chars[pos] == '_' {
257 while pos > 0
259 && (chars[pos.saturating_sub(1)].is_alphanumeric()
260 || chars[pos.saturating_sub(1)] == '_')
261 {
262 pos = pos.saturating_sub(1);
263 }
264 } else if !chars[pos].is_whitespace() {
265 while pos > 0
267 && !chars[pos.saturating_sub(1)].is_whitespace()
268 && !chars[pos.saturating_sub(1)].is_alphanumeric()
269 && chars[pos.saturating_sub(1)] != '_'
270 {
271 pos = pos.saturating_sub(1);
272 }
273 }
274 }
275
276 state.cursor_col = pos;
277 }
278
279 Self::ensure_column_bounds(state);
280 Vec::new()
281 }
282
283 pub fn move_to_line_start(state: &mut SourcePanelState) -> Vec<Action> {
285 state.cursor_col = 0;
287
288 Self::ensure_column_bounds(state);
289 Vec::new()
290 }
291
292 pub fn move_to_line_end(state: &mut SourcePanelState) -> Vec<Action> {
294 if let Some(current_line) = state.content.get(state.cursor_line) {
295 if current_line.is_empty() {
296 state.cursor_col = 0;
297 } else {
298 state.cursor_col = current_line.chars().count().saturating_sub(1);
299 }
300 } else {
301 state.cursor_col = 0;
302 }
303
304 Self::ensure_column_bounds(state);
305 Vec::new()
306 }
307
308 pub fn jump_to_line(state: &mut SourcePanelState, line_number: usize) -> Vec<Action> {
310 if line_number > 0 && line_number <= state.content.len() {
311 state.cursor_line = line_number - 1; state.cursor_col = 0;
313 }
314 Vec::new()
315 }
316
317 pub fn go_to_line(state: &mut SourcePanelState, line_number: usize) -> Vec<Action> {
319 Self::jump_to_line(state, line_number)
320 }
321
322 pub fn handle_number_input(state: &mut SourcePanelState, ch: char) -> Vec<Action> {
324 if ch.is_ascii_digit() {
325 state.number_buffer.push(ch);
326 }
327 Vec::new()
328 }
329
330 pub fn handle_g_key(state: &mut SourcePanelState) -> Vec<Action> {
332 if state.g_pressed {
333 state.g_pressed = false;
335 state.number_buffer.clear();
336 Self::move_to_top(state)
337 } else {
338 state.g_pressed = true;
339 Vec::new()
340 }
341 }
342
343 pub fn handle_shift_g_key(state: &mut SourcePanelState) -> Vec<Action> {
345 if state.number_buffer.is_empty() {
346 Self::move_to_bottom(state)
348 } else {
349 if let Ok(line_num) = state.number_buffer.parse::<usize>() {
351 let result = Self::jump_to_line(state, line_num);
352 state.number_buffer.clear();
353 state.g_pressed = false;
354 result
355 } else {
356 state.number_buffer.clear();
357 state.g_pressed = false;
358 Vec::new()
359 }
360 }
361 }
362
363 pub fn load_source(
365 state: &mut SourcePanelState,
366 file_path: String,
367 highlight_line: Option<usize>,
368 ) -> Vec<Action> {
369 tracing::info!("wtf {file_path}");
370 match std::fs::read_to_string(&file_path) {
371 Ok(content) => {
372 let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
373 state.file_path = Some(file_path.clone());
374 state.content = if lines.is_empty() {
375 vec!["// Empty file".to_string()]
376 } else {
377 lines
378 };
379
380 state.language = Self::detect_language(&file_path);
382
383 if let Some(line) = highlight_line {
385 state.cursor_line = line.saturating_sub(1); let estimated_half_height = 15; if state.cursor_line >= estimated_half_height {
390 state.scroll_offset = state.cursor_line - estimated_half_height;
391 } else {
392 state.scroll_offset = 0;
393 }
394 } else {
395 state.cursor_line = 0;
396 state.scroll_offset = 0;
397 }
398 state.cursor_col = 0;
399 state.horizontal_scroll_offset = 0;
400
401 state.search_query.clear();
403 state.search_matches.clear();
404 state.current_match = None;
405 state.mode = SourcePanelMode::Normal;
406 }
407 Err(e) => {
408 let error_kind = if e.kind() == std::io::ErrorKind::NotFound {
410 "File not found"
411 } else {
412 "Cannot read file"
413 };
414
415 let (dwarf_dir, _, _) = Self::split_dwarf_path(&file_path);
417
418 let error_message = format!(
419 "{error_kind}: {file_path}\n\
420 Error: {e}\n\n\
421 💡 Possible solution:\n\n\
422 If source was compiled on a different machine or moved,\n\
423 configure path mapping with 'srcpath' command:\n\n\
424 srcpath map {dwarf_dir} /your/local/path\n\
425 srcpath add /additional/search/directory\n\n\
426 This will map the DWARF compilation directory to your local path,\n\
427 and all files under it will be resolved correctly.\n\n\
428 Type 'help srcpath' for more information.\n\n\
429 📘 No source available? You can hide the Source panel:\n\
430 ui source off # in UI command mode\n\
431 --no-source-panel # CLI flag\n\
432 [ui].show_source_panel=false # in config.toml"
433 );
434
435 Self::show_error(state, &file_path, error_message);
436 }
437 }
438 Vec::new()
439 }
440
441 pub fn clear_source(state: &mut SourcePanelState) -> Vec<Action> {
443 state.content = vec!["// No source code loaded".to_string()];
444 state.file_path = None;
445 state.cursor_line = 0;
446 state.cursor_col = 0;
447 state.scroll_offset = 0;
448 state.horizontal_scroll_offset = 0;
449 state.search_query.clear();
450 state.search_matches.clear();
451 state.current_match = None;
452 state.mode = SourcePanelMode::Normal;
453 Vec::new()
454 }
455
456 pub fn clear_all_state(state: &mut SourcePanelState) -> Vec<Action> {
458 state.search_query.clear();
460 state.search_matches.clear();
461 state.current_match = None;
462
463 state.number_buffer.clear();
465 state.expecting_g = false;
466 state.g_pressed = false;
467
468 state.mode = SourcePanelMode::Normal;
470
471 Vec::new()
472 }
473
474 fn split_dwarf_path(file_path: &str) -> (String, String, String) {
480 use std::path::Path;
481
482 if file_path.is_empty() {
484 return (
485 "<unknown>".to_string(),
486 "<unknown>".to_string(),
487 "<unknown>".to_string(),
488 );
489 }
490
491 let path = Path::new(file_path);
492 let basename = path
493 .file_name()
494 .and_then(|n| n.to_str())
495 .unwrap_or("unknown")
496 .to_string();
497
498 let source_markers = ["src", "include", "lib", "source", "sources", "inc", "libs"];
500
501 let components: Vec<_> = path.components().collect();
503
504 if components.is_empty() {
506 return ("<unknown>".to_string(), file_path.to_string(), basename);
507 }
508
509 for (idx, component) in components.iter().enumerate() {
510 if let Some(comp_str) = component.as_os_str().to_str() {
511 if source_markers.contains(&comp_str) {
512 let dwarf_dir: std::path::PathBuf = components[..idx].iter().collect();
514 let relative: std::path::PathBuf = components[idx..].iter().collect();
515
516 return (
517 dwarf_dir.to_string_lossy().to_string(),
518 relative.to_string_lossy().to_string(),
519 basename,
520 );
521 }
522 }
523 }
524
525 let dwarf_dir = path
527 .parent()
528 .map(|p| p.to_string_lossy().to_string())
529 .unwrap_or_else(|| "<unknown>".to_string());
530
531 (dwarf_dir, basename.clone(), basename)
532 }
533
534 fn show_error(state: &mut SourcePanelState, file_path: &str, error_message: String) {
536 let (dwarf_dir, relative_path, basename) = Self::split_dwarf_path(file_path);
537
538 let mut content = vec![
540 "// Source code loading failed".to_string(),
541 "//".to_string(),
542 format!("// DWARF Directory: {}", dwarf_dir),
543 format!("// Relative Path: {}", relative_path),
544 format!("// Basename: {}", basename),
545 "//".to_string(),
546 ];
547
548 for line in error_message.lines() {
550 if line.is_empty() {
551 content.push("//".to_string());
552 } else {
553 content.push(format!("// {line}"));
554 }
555 }
556
557 state.content = content;
558 state.file_path = Some(file_path.to_string());
559 state.cursor_line = 0;
560 state.cursor_col = 0;
561 state.scroll_offset = 0;
562 state.horizontal_scroll_offset = 0;
563 }
564
565 pub fn show_error_message(state: &mut SourcePanelState, error_message: String) {
567 Self::show_error(state, "(no file)", error_message);
568 }
569
570 fn detect_language(file_path: &str) -> String {
572 if let Some(extension) = file_path.rsplit('.').next() {
573 match extension.to_lowercase().as_str() {
574 "c" => "c".to_string(),
575 "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp".to_string(),
576 "rs" => "rust".to_string(),
577 _ => "c".to_string(), }
579 } else {
580 "c".to_string() }
582 }
583
584 pub fn ensure_cursor_visible(state: &mut SourcePanelState, panel_height: u16) {
586 let visible_lines = panel_height.saturating_sub(2) as usize; let total_lines = state.content.len();
588
589 if visible_lines == 0 || total_lines == 0 {
590 return;
591 }
592
593 let vertical_scrolloff = (visible_lines / 5).clamp(2, 5);
595
596 let cursor_in_view = state.cursor_line.saturating_sub(state.scroll_offset);
598
599 if cursor_in_view < vertical_scrolloff && state.scroll_offset > 0 {
601 state.scroll_offset = state.cursor_line.saturating_sub(vertical_scrolloff);
603 }
604 else if cursor_in_view >= visible_lines.saturating_sub(vertical_scrolloff) {
606 let target_pos = visible_lines.saturating_sub(vertical_scrolloff + 1);
608 state.scroll_offset = state.cursor_line.saturating_sub(target_pos);
609 }
610
611 let max_scroll = total_lines.saturating_sub(visible_lines);
613 state.scroll_offset = state.scroll_offset.min(max_scroll);
614
615 if state.cursor_line < vertical_scrolloff {
617 state.scroll_offset = 0;
618 }
619
620 if state.cursor_line >= total_lines.saturating_sub(vertical_scrolloff)
622 && total_lines > visible_lines
623 {
624 let lines_after_cursor = total_lines.saturating_sub(state.cursor_line + 1);
626 if lines_after_cursor < vertical_scrolloff {
627 state.scroll_offset = max_scroll;
629 }
630 }
631 }
632
633 fn ensure_column_bounds(state: &mut SourcePanelState) {
635 if let Some(current_line) = state.content.get(state.cursor_line) {
636 if current_line.is_empty() {
637 state.cursor_col = 0;
639 } else {
640 let max_column = current_line.chars().count().saturating_sub(1); if state.cursor_col > max_column {
643 state.cursor_col = max_column;
644 }
645 }
646 }
647 }
648
649 pub fn ensure_horizontal_cursor_visible(state: &mut SourcePanelState, panel_width: u16) {
651 if let Some(current_line_content) = state.content.get(state.cursor_line) {
652 const LINE_NUMBER_WIDTH: u16 = 5; const BORDER_WIDTH: u16 = 2; let available_width =
657 (panel_width.saturating_sub(LINE_NUMBER_WIDTH + BORDER_WIDTH)) as usize;
658
659 if available_width == 0 {
660 return; }
662
663 let line_char_count = current_line_content.chars().count();
664
665 let horizontal_scrolloff = (available_width / 4).clamp(3, 8); let cursor_in_view = state
670 .cursor_col
671 .saturating_sub(state.horizontal_scroll_offset);
672
673 if cursor_in_view < horizontal_scrolloff && state.cursor_col >= horizontal_scrolloff {
675 state.horizontal_scroll_offset =
677 state.cursor_col.saturating_sub(horizontal_scrolloff);
678 }
679 else if cursor_in_view + horizontal_scrolloff >= available_width {
681 let target_pos = available_width.saturating_sub(horizontal_scrolloff + 1);
683 state.horizontal_scroll_offset = state.cursor_col.saturating_sub(target_pos);
684 }
685
686 if line_char_count <= available_width && state.horizontal_scroll_offset > 0 {
688 let max_scroll_for_short_line =
690 line_char_count.saturating_sub(available_width / 2).max(0);
691 state.horizontal_scroll_offset = state
692 .horizontal_scroll_offset
693 .min(max_scroll_for_short_line);
694 }
695
696 if state.cursor_col < horizontal_scrolloff {
698 state.horizontal_scroll_offset = 0;
699 } else if state.horizontal_scroll_offset > state.cursor_col {
700 state.horizontal_scroll_offset =
702 state.cursor_col.saturating_sub(horizontal_scrolloff);
703 }
704
705 if state.cursor_col < state.horizontal_scroll_offset {
707 state.horizontal_scroll_offset = state.cursor_col;
708 } else if state.cursor_col >= state.horizontal_scroll_offset + available_width {
709 state.horizontal_scroll_offset =
710 state.cursor_col.saturating_sub(available_width - 1);
711 }
712 }
713 }
714}