diff --git a/README.md b/README.md index 7a803d4..47465b9 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,4 @@ ## Issues to fix - New file creation should work with non-existent directories -- Search results should be ranked by 'path elements shared with original path' -- Buffer switcher and search results should be filterable by elements of the path -- Buffer switcher and search results should disambiguate paths with the same filename - Undo history changes should not join so easily \ No newline at end of file diff --git a/src/terminal.rs b/src/terminal.rs index d29ce1f..c1ccc3e 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -60,8 +60,8 @@ impl Area { } pub struct Rect<'a> { - fg: Color, - bg: Color, + pub fg: Color, + pub bg: Color, area: Area, fb: &'a mut Framebuffer, has_focus: bool, @@ -177,6 +177,11 @@ impl<'a> Rect<'a> { } } + /// `with_bg`, but only if background color is not already set. + pub fn with_bg_preference(&mut self, bg: Color) -> Rect<'_> { + self.with_bg(if self.bg == Color::Reset { bg } else { self.bg }) + } + pub fn with_focus(&mut self, focus: bool) -> Rect<'_> { Rect { fg: self.fg, diff --git a/src/ui/input.rs b/src/ui/input.rs index bddc8ec..282ebc8 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -10,11 +10,13 @@ enum Mode { Doc, Prompt, Filter, + SearchResult, } #[derive(Clone, Default)] pub struct Input { pub mode: Mode, + line_offset: usize, // x/y location in the buffer that the pane is trying to focus on pub focus: [isize; 2], // Remember the last area for things like scrolling @@ -38,6 +40,14 @@ impl Input { } } + pub fn search_result(line_offset: usize) -> Self { + Self { + mode: Mode::SearchResult, + line_offset, + ..Self::default() + } + } + pub fn focus(&mut self, coord: [isize; 2]) { for i in 0..2 { self.focus[i] = self.focus[i] @@ -118,7 +128,11 @@ impl Input { Ok(Resp::handled(None)) } Some(Action::GotoLine(line)) => { - buffer.goto_cursor(cursor_id, [0, line], true); + buffer.goto_cursor( + cursor_id, + [0, (line - self.line_offset as isize).max(0)], + true, + ); self.refocus(buffer, cursor_id); Ok(Resp::handled(None)) } @@ -237,20 +251,30 @@ impl Input { outer_frame: &mut Rect, ) { // Add frame - let mut frame = outer_frame.with_border( - if outer_frame.has_focus() { - &state.theme.focus_border - } else { - &state.theme.border - }, - title.as_deref(), - ); + let mut frame = if matches!(self.mode, Mode::SearchResult) { + outer_frame.rect([0; 2], [!0; 2]) + } else { + outer_frame.with_border( + if outer_frame.has_focus() { + &state.theme.focus_border + } else { + &state.theme.border + }, + title.as_deref(), + ) + }; - let line_num_w = buffer.text.lines().count().max(1).ilog10() as usize + 1; - let margin_w = match self.mode { - Mode::Prompt => 2, - Mode::Filter => 0, - Mode::Doc => line_num_w + 2, + let (line_num_w, margin_w) = match self.mode { + Mode::Prompt => (2, 0), + Mode::Filter => (0, 0), + Mode::Doc => { + let line_num_w = (self.line_offset + buffer.text.lines().count()) + .max(1) + .ilog10() as usize + + 1; + (line_num_w, line_num_w + 2) + } + Mode::SearchResult => (4, 6), }; self.last_area = frame.rect([margin_w, 0], [!0, !0]).area(); @@ -279,16 +303,19 @@ impl Input { Mode::Filter => frame.rect([0, 0], frame.size()), Mode::Prompt => frame .rect([0, i], [1, 1]) - .with_bg(state.theme.margin_bg) + .with_bg_preference(state.theme.margin_bg) .with_fg(state.theme.margin_line_num) .fill(' ') .text([0, 0], ">"), - Mode::Doc => frame + Mode::Doc | Mode::SearchResult => frame .rect([0, i], [margin_w, 1]) - .with_bg(state.theme.margin_bg) + .with_bg_preference(state.theme.margin_bg) .with_fg(state.theme.margin_line_num) .fill(' ') - .text([1, 0], &format!("{:>line_num_w$}", line_num + 1)), + .text( + [1, 0], + &format!("{:>line_num_w$}", self.line_offset + line_num + 1), + ), }; let line_highlight_selected = matches!(self.mode, Mode::Doc) @@ -337,7 +364,7 @@ impl Input { } else if line_highlight_selected && frame.has_focus() { state.theme.line_select_bg } else { - Color::Reset + frame.bg } } }; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1491c3f..c3ed400 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -184,7 +184,7 @@ impl Options { } } -impl Element for Options { +impl Element for Options { fn handle(&mut self, state: &mut State, event: Event) -> Result, Event> { match event.to_action(|e| e.to_go().or_else(|| e.to_move())) { Some(Action::Move( @@ -216,7 +216,7 @@ impl Element for Options { Some(Action::Go) => { if self.selected < self.ranking.len() { Ok(Resp::end_with( - self.options[self.ranking[self.selected]].clone(), + self.options.remove(self.ranking[self.selected]), None, )) } else { diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs index 39c5374..325430d 100644 --- a/src/ui/prompt.rs +++ b/src/ui/prompt.rs @@ -244,16 +244,24 @@ impl Element<()> for Switcher { ) .map(Resp::into_can_end); // Score entries - let filter = self.buffer.text.to_string(); + let filter = self.buffer.text.to_string().to_lowercase(); self.options.apply_scoring(|b| { let Some(buffer) = state.buffers.get(*b) else { return None; }; - let name = buffer.name()?; + let name = buffer.name().as_deref().unwrap_or("").to_lowercase(); + let parent = buffer + .path + .as_ref() + .and_then(|p| Some(p.parent()?.to_str()?.to_lowercase())); if name.starts_with(&filter) { Some(1) } else if name.contains(&filter) { Some(2) + } else if let Some(parent) = parent + && parent.contains(&filter) + { + Some(3) } else { None } @@ -284,7 +292,18 @@ impl Visual for BufferId { let Some(buffer) = state.buffers.get(*self) else { return; }; - frame.text([0, 0], buffer.name().as_deref().unwrap_or("")); + frame + .with_fg(state.theme.option_file) + .text([0, 0], buffer.name().as_deref().unwrap_or("")); + let path_x = (frame.size()[0] as isize / 3).max(32); + frame.with_fg(state.theme.option_dir).text( + [path_x, 0], + buffer + .path + .as_ref() + .and_then(|p| p.parent()?.to_str()) + .unwrap_or(""), + ); } } diff --git a/src/ui/search.rs b/src/ui/search.rs index 59db809..457dcf0 100644 --- a/src/ui/search.rs +++ b/src/ui/search.rs @@ -1,35 +1,42 @@ use super::*; use crate::state::{Buffer, CursorId}; -use std::fs; +use std::{fs, path::Path}; pub struct Searcher { options: Options, path: PathBuf, + search_path: PathBuf, needle: String, // Filter buffer: Buffer, cursor_id: CursorId, input: Input, - preview: Option<(Buffer, CursorId, Input, SearchResult)>, + preview: Option<(Buffer, CursorId, Input, SearchLoc)>, } impl Searcher { - pub fn new(mut path: PathBuf, needle: String) -> Self { - let path = loop { - if let Ok(mut entries) = fs::read_dir(&path) + pub fn new(path: PathBuf, needle: String) -> Self { + let mut search_path = path.clone(); + let search_path = loop { + if let Ok(mut entries) = fs::read_dir(&search_path) && entries.any(|e| { e.map_or(false, |e| { e.file_name() == ".git" && e.file_type().map_or(false, |t| t.is_dir()) }) }) { - break path; - } else if !path.pop() { + break search_path; + } else if !search_path.pop() { break std::env::current_dir().expect("No cwd"); } }; - fn search_in(path: &PathBuf, needle: &str, results: &mut Vec) { + fn search_in( + search_path: &Path, + path: &Path, + needle: &str, + results: &mut Vec, + ) { // Cap reached! if results.len() < 500 { // Skip hidden files @@ -51,16 +58,31 @@ impl Searcher { for (line_idx, line_text) in s.lines().enumerate().filter(|(_, l)| l.contains(needle)) { + let mut line_buffer = Buffer::new( + false, + line_text.trim().chars().collect(), + path.to_path_buf(), + ); results.push(SearchResult { - path: path.clone(), - line_idx, - line_text: line_text.trim().to_string(), + loc: SearchLoc { + path: path.to_path_buf(), + line_idx, + }, + rdir: format!( + "./{}", + path.parent() + .and_then(|p| p.strip_prefix(search_path).ok()?.to_str()) + .unwrap_or("unknown") + ), + line_input: Input::search_result(line_idx), + line_cursor: line_buffer.start_session(), + line_buffer, }); } } else if let Ok(entries) = fs::read_dir(path) { // Special case, ignore Rust target dir to prevent searching too many places { - let mut path = path.clone(); + let mut path = path.to_path_buf(); path.push("CACHEDIR.TAG"); if path.exists() { return; @@ -69,27 +91,30 @@ impl Searcher { for entry in entries { let Ok(entry) = entry else { continue }; - search_in(&entry.path(), needle, results); + search_in(search_path, &entry.path(), needle, results); } } } } let mut results = Vec::new(); - search_in(&path, &needle, &mut results); + search_in(&search_path, &search_path, &needle, &mut results); let mut buffer = Buffer::default(); let cursor_id = buffer.start_session(); - Self { + let mut this = Self { options: Options::new(results), path, + search_path, needle, cursor_id, buffer, input: Input::filter(), preview: None, - } + }; + this.update_completions(); + this } pub fn requested_height(&self) -> usize { @@ -100,13 +125,34 @@ impl Searcher { fn update_completions(&mut self) { let filter = self.buffer.text.to_string().to_lowercase(); self.options.apply_scoring(|e| { - let name = format!("{}", e.path.display()).to_lowercase(); + let name = e + .loc + .path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("") + .to_lowercase(); + let parent = e + .loc + .path + .parent() + .and_then(|f| Some(f.to_str()?.to_lowercase())); + let unshared_components = e + .loc + .path + .ancestors() + .map(|a| if self.path.starts_with(a) { -1 } else { 1 }) + .sum::(); if name == filter { - Some((0, name.chars().count())) + Some((0, unshared_components)) } else if name.starts_with(&filter) { - Some((1, name.chars().count())) + Some((1, unshared_components)) } else if name.contains(&filter) { - Some((2, name.chars().count())) + Some((2, unshared_components)) + } else if let Some(parent) = parent + && parent.contains(&filter) + { + Some((3, unshared_components)) } else { None } @@ -123,8 +169,8 @@ impl Element<()> for Searcher { _ => match self.options.handle(state, event).map(Resp::into_ended) { // Selecting a directory enters the directory Ok(Some(result)) => Ok(Resp::end(Some(Event::Action(Action::OpenFile( - result.path, - result.line_idx, + result.loc.path, + result.loc.line_idx, ))))), Ok(None) => Ok(Resp::handled(None)), Err(event) => { @@ -162,27 +208,46 @@ impl Element<()> for Searcher { } #[derive(Clone, PartialEq)] -pub struct SearchResult { - pub path: PathBuf, - pub line_idx: usize, - pub line_text: String, +struct SearchLoc { + path: PathBuf, + line_idx: usize, +} + +struct SearchResult { + loc: SearchLoc, + rdir: String, + line_input: Input, + line_cursor: CursorId, + line_buffer: Buffer, } impl Visual for SearchResult { fn render(&mut self, state: &State, frame: &mut Rect) { - let name = match self.path.file_name().and_then(|n| n.to_str()) { + let name = match self.loc.path.file_name().and_then(|n| n.to_str()) { Some(name) => format!("{name}"), None => format!("Unknown"), }; + let col_a = (frame.size()[0] / 5).max(20); + let col_b = frame.size()[0] / 3; + // Filename frame + .rect([0, 0], [col_a, !0]) .with_fg(state.theme.option_file) - .text([0, 0], &format!("{name}:{}", self.line_idx + 1)); - frame.with_fg(state.theme.margin_line_num).with(|f| { - f.text( - [f.size()[0] as isize / 3, 0], - &format!("{}", self.line_text), - ); - }); + .text([0, 0], &format!("{name}:{}", self.loc.line_idx + 1)); + // Path + frame + .rect([col_a, 0], [col_b, !0]) + .with_fg(state.theme.option_dir) + .text([0, 0], &self.rdir); + // Code snippet + self.line_input.render( + state, + None, + &self.line_buffer, + self.line_cursor, + None, + &mut frame.rect([col_a + col_b, 0], [!0, !0]), + ); } } @@ -200,18 +265,18 @@ impl Visual for Searcher { self.preview = self.options.selected().and_then(|result| { self.preview .take() - .filter(|(_, _, _, r)| r == result) + .filter(|(_, _, _, loc)| loc == &result.loc) .or_else(|| { - let mut buffer = Buffer::open(result.path.clone()).ok()?; + let mut buffer = Buffer::open(result.loc.path.clone()).ok()?; let cursor_id = buffer.start_session(); let mut input = Input::default(); - buffer.goto_cursor(cursor_id, [0, result.line_idx as isize], true); - input.focus([0, result.line_idx as isize - preview_sz as isize / 2]); - Some((buffer, cursor_id, input, result.clone())) + buffer.goto_cursor(cursor_id, [0, result.loc.line_idx as isize], true); + input.focus([0, result.loc.line_idx as isize - preview_sz as isize / 2]); + Some((buffer, cursor_id, input, result.loc.clone())) }) }); - if let Some((buffer, cursor_id, input, result)) = &mut self.preview { + if let Some((buffer, cursor_id, input, _)) = &mut self.preview { frame.rect([0, 0], [frame.size()[0], preview_sz]).with(|f| { input.render(state, buffer.name().as_deref(), buffer, *cursor_id, None, f) });