diff --git a/CMakeLists.txt b/CMakeLists.txt index 9ca960d17..5619070c7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -121,7 +121,6 @@ set(FISH_SRCS src/input_common.cpp src/input.cpp src/output.cpp - src/pager.cpp src/parse_util.cpp src/path.cpp src/reader.cpp diff --git a/fish-rust/build.rs b/fish-rust/build.rs index eb816f545..be74785bc 100644 --- a/fish-rust/build.rs +++ b/fish-rust/build.rs @@ -91,6 +91,7 @@ fn main() { "fish-rust/src/kill.rs", "fish-rust/src/operation_context.rs", "fish-rust/src/output.rs", + "fish-rust/src/pager.rs", "fish-rust/src/parse_constants.rs", "fish-rust/src/parser.rs", "fish-rust/src/parse_tree.rs", diff --git a/fish-rust/src/complete.rs b/fish-rust/src/complete.rs index a963af3cb..b04f68b73 100644 --- a/fish-rust/src/complete.rs +++ b/fish-rust/src/complete.rs @@ -2639,6 +2639,10 @@ unsafe impl cxx::ExternType for CompletionListFfi { type Id = cxx::type_id!("CompletionListFfi"); type Kind = cxx::kind::Opaque; } +unsafe impl cxx::ExternType for Completion { + type Id = cxx::type_id!("Completion"); + type Kind = cxx::kind::Opaque; +} fn new_completion() -> Box { Box::new(Completion::new( diff --git a/fish-rust/src/ffi.rs b/fish-rust/src/ffi.rs index c4cb7ab25..f47e32ab2 100644 --- a/fish-rust/src/ffi.rs +++ b/fish-rust/src/ffi.rs @@ -105,11 +105,6 @@ generate!("commandline_get_state_text_ffi") generate!("completion_apply_to_command_line") - generate!("pager_t") - generate!("page_rendering_t") - generate!("pager_set_term_size_ffi") - generate!("pager_update_rendering_ffi") - generate!("get_history_variable_text_ffi") generate_pod!("escape_string_style_t") @@ -173,8 +168,6 @@ impl Repin for IoStreams<'_> {} impl Repin for wcstring_list_ffi_t {} impl Repin for rgb_color_t {} impl Repin for OutputStreamFfi<'_> {} -impl Repin for pager_t {} -impl Repin for page_rendering_t {} pub use autocxx::c_int; pub use ffi::*; diff --git a/fish-rust/src/highlight.rs b/fish-rust/src/highlight.rs index 8b6bb82bf..aec1ad3f2 100644 --- a/fish-rust/src/highlight.rs +++ b/fish-rust/src/highlight.rs @@ -1635,6 +1635,7 @@ fn default() -> Self { mod highlight_ffi { /// Describes the role of a span of text. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] + #[repr(u8)] pub enum HighlightRole { normal, // normal text error, // error diff --git a/fish-rust/src/lib.rs b/fish-rust/src/lib.rs index 7c4cba543..e90083cea 100644 --- a/fish-rust/src/lib.rs +++ b/fish-rust/src/lib.rs @@ -74,6 +74,7 @@ mod null_terminated_array; mod operation_context; mod output; +mod pager; mod parse_constants; mod parse_execution; mod parse_tree; diff --git a/fish-rust/src/pager.rs b/fish-rust/src/pager.rs new file mode 100644 index 000000000..c425b310c --- /dev/null +++ b/fish-rust/src/pager.rs @@ -0,0 +1,1366 @@ +//! Pager support. + +use cxx::{CxxWString, UniquePtr}; +use std::collections::hash_map::Entry; +use std::collections::HashMap; + +use crate::common::{ + escape_string, get_ellipsis_char, get_ellipsis_str, EscapeFlags, EscapeStringStyle, +}; +use crate::compat::MB_CUR_MAX; +use crate::complete::{Completion, CompletionListFfi}; +use crate::editable_line::EditableLine; +use crate::fallback::{fish_wcswidth, fish_wcwidth}; +use crate::future::IsSomeAnd; +use crate::highlight::{highlight_shell, HighlightRole, HighlightSpec}; +use crate::operation_context::OperationContext; +use crate::screen::{Line, ScreenData}; +use crate::termsize::Termsize; +use crate::wchar::prelude::*; +use crate::wchar_ffi::{AsWstr, WCharFromFFI, WCharToFFI}; +use crate::wcstringutil::string_fuzzy_match_string; + +/// Represents rendering from the pager. +#[derive(Default)] +pub struct PageRendering { + pub term_width: Option, + pub term_height: Option, + pub rows: usize, + pub cols: usize, + pub row_start: usize, + pub row_end: usize, + pub selected_completion_idx: Option, + pub screen_data: ScreenData, + + pub remaining_to_disclose: usize, + + pub search_field_shown: bool, + pub search_field_line: EditableLine, +} + +impl PageRendering { + // Returns a rendering with invalid data, useful to indicate "no rendering". + pub fn new() -> Self { + Default::default() + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum SelectionMotion { + // Visual directions. + North, + East, + South, + West, + PageNorth, + PageSouth, + + // Logical directions. + Next, + Prev, + + // Special value that means deselect. + Deselect, +} + +/// The space between adjacent completions. +const PAGER_SPACER_STRING: &wstr = L!(" "); + +// How many rows we will show in the "initial" pager. +const PAGER_UNDISCLOSED_MAX_ROWS: usize = 4; + +/// The minimum width (in characters) the terminal must to show completions at all. +const PAGER_MIN_WIDTH: usize = 16; + +/// Minimum height to show completions +const PAGER_MIN_HEIGHT: usize = 4; + +/// The maximum number of columns of completion to attempt to fit onto the screen. +const PAGER_MAX_COLS: usize = 6; + +/// Width of the search field. +const PAGER_SEARCH_FIELD_WIDTH: usize = 12; + +/// Text we use for the search field. +const SEARCH_FIELD_PROMPT: &wstr = L!("search: "); + +const PAGER_SELECTION_NONE: usize = usize::MAX; + +#[derive(Default)] +pub struct Pager { + pub available_term_width: usize, + pub available_term_height: usize, + + pub selected_completion_idx: Option, + pub suggested_row_start: usize, + + // Fully disclosed means that we show all completions. + pub fully_disclosed: bool, + + // Whether we show the search field. + pub search_field_shown: bool, + + // The filtered list of completion infos. + completion_infos: Vec, + + // The unfiltered list. Note there's a lot of duplication here. + unfiltered_completion_infos: Vec, + + // This tracks if the completion list has been changed since we last rendered. If yes, + // then we definitely need to re-render. + have_unrendered_completions: bool, + + prefix: WString, + highlight_prefix: bool, + + // The text of the search field. + pub search_field_line: EditableLine, + + // Extra text to display at the bottom of the pager. + pub extra_progress_text: WString, +} + +impl Pager { + // Returns the index of the completion that should draw selected, using the given number of + // columns. + pub fn visual_selected_completion_index(&self, rows: usize, cols: usize) -> Option { + // No completions -> no selection. + if self.completion_infos.is_empty() { + return None; + } + + let result = self.selected_completion_idx; + if result == Some(0) { + return result; + } + + if rows == 0 || cols == 0 { + return None; + } + result.map(|mut result| { + // If the selected completion is beyond the last selection, go left by columns until it's + // within it. This is how we implement "column memory". + while result >= self.completion_infos.len() && result >= rows { + result -= rows; + } + + // If we are still beyond the last selection, clamp it. + if result >= self.completion_infos.len() { + result = self.completion_infos.len() - 1; + } + result + }) + } + + /// Try to print the list of completions lst with the prefix prefix using cols as the number of + /// columns. Return true if the completion list was printed, false if the terminal is too narrow for + /// the specified number of columns. Always succeeds if cols is 1. + fn completion_try_print( + &self, + cols: usize, + prefix: &wstr, + lst: &[PagerComp], + rendering: &mut PageRendering, + suggested_start_row: usize, + ) -> bool { + assert!(cols > 0); + // The calculated preferred width of each column. + let mut width_by_column = [0; PAGER_MAX_COLS]; + + // Skip completions on tiny terminals. + if self.available_term_width < PAGER_MIN_WIDTH + || self.available_term_height < PAGER_MIN_HEIGHT + { + return true; + } + + // Compute the effective term width and term height, accounting for disclosure. + let term_width = self.available_term_width; + let mut term_height = self.available_term_height + // we always subtract 1 to make room for a comment row + - 1 - if self.search_field_shown { 1 } else { 0 }; + if !self.fully_disclosed { + // We disclose between half and the entirety of the terminal height, + // but at least 4 rows. + // + // We do this so we show a useful amount but don't force fish to + // THE VERY TOP, which is jarring. + term_height = std::cmp::min( + term_height, + std::cmp::max(term_height / 2, PAGER_UNDISCLOSED_MAX_ROWS), + ); + } + + let row_count = divide_round_up(lst.len(), cols); + + // We have more to disclose if we are not fully disclosed and there's more rows than we have in + // our term height. + if !self.fully_disclosed && row_count > term_height { + rendering.remaining_to_disclose = row_count - term_height; + } else { + rendering.remaining_to_disclose = 0; + } + + // If we have only one row remaining to disclose, then squelch the comment row. This prevents us + // from consuming a line to show "...and 1 more row". + if rendering.remaining_to_disclose == 1 { + term_height += 1; + rendering.remaining_to_disclose = 0; + } + + // Calculate how wide the list would be. + for (col, col_width) in width_by_column.iter_mut().enumerate() { + for row in 0..row_count { + let comp_idx = col * row_count + row; + if comp_idx >= lst.len() { + continue; + } + let c = &lst[comp_idx]; + *col_width = std::cmp::max(*col_width, c.preferred_width()); + } + } + + let print = if cols == 1 { + // Force fit if one column. + width_by_column[0] = std::cmp::min(width_by_column[0], term_width); + true + } else { + // Compute total preferred width, plus spacing + let mut total_width_needed: usize = width_by_column.iter().sum(); + total_width_needed += (cols - 1) * PAGER_SPACER_STRING.len(); + total_width_needed <= term_width + }; + if !print { + return false; // no need to continue + } + + // Determine the starting and stop row. + let start_row; + let stop_row; + if row_count <= term_height { + // Easy, we can show everything. + start_row = 0; + stop_row = row_count; + } else { + // We can only show part of the full list. Determine which part based on the + // suggested_start_row. + assert!(row_count > term_height); + let last_starting_row = row_count - term_height; + start_row = std::cmp::min(suggested_start_row, last_starting_row); + stop_row = start_row + term_height; + assert!(start_row <= last_starting_row); + } + + assert!(stop_row >= start_row); + assert!(stop_row <= row_count); + assert!(stop_row - start_row <= term_height); + self.completion_print( + cols, + &width_by_column, + start_row, + stop_row, + prefix, + lst, + rendering, + ); + + // Add the progress line. It's a "more to disclose" line if necessary, or a row listing if + // it's scrollable; otherwise ignore it. + // We should never have one row remaining to disclose (else we would have just disclosed it) + let mut progress_text = WString::new(); + assert_ne!(rendering.remaining_to_disclose, 1); + if rendering.remaining_to_disclose > 1 { + progress_text = wgettext_fmt!( + "%lsand %lu more rows", + get_ellipsis_str(), + rendering.remaining_to_disclose + ); + } else if start_row > 0 || stop_row < row_count { + // We have a scrollable interface. The +1 here is because we are zero indexed, but want + // to present things as 1-indexed. We do not add 1 to stop_row or row_count because + // these are the "past the last value". + progress_text = + wgettext_fmt!("rows %lu to %lu of %lu", start_row + 1, stop_row, row_count); + } else if self.search_field_shown && self.completion_infos.is_empty() { + // Everything is filtered. + progress_text = wgettext!("(no matches)").to_owned(); + } + if !self.extra_progress_text.is_empty() { + if !progress_text.is_empty() { + progress_text.push_str(". "); + } + progress_text.push_utfstr(&self.extra_progress_text); + } + + if !progress_text.is_empty() { + let line = rendering.screen_data.add_line(); + let spec = HighlightSpec::with_fg_bg( + HighlightRole::pager_progress, + HighlightRole::pager_progress, + ); + print_max( + &progress_text, + spec, + term_width, + /*has_more=*/ true, + line, + ); + } + + if !self.search_field_shown { + return true; + } + + // Add the search field. + let mut search_field_text = self.search_field_line.text().to_owned(); + // Append spaces to make it at least the required width. + if search_field_text.len() < PAGER_SEARCH_FIELD_WIDTH { + search_field_text.extend( + std::iter::repeat(' ').take(PAGER_SEARCH_FIELD_WIDTH - search_field_text.len()), + ); + } + let search_field = rendering.screen_data.insert_line_at_index(0); + + // We limit the width to term_width - 1. + let mut underline = HighlightSpec::new(); + underline.force_underline = true; + + let mut search_field_remaining = term_width - 1; + search_field_remaining -= print_max( + SEARCH_FIELD_PROMPT, + HighlightSpec::new(), + search_field_remaining, + false, + search_field, + ); + search_field_remaining -= print_max( + &search_field_text, + underline, + search_field_remaining, + false, + search_field, + ); + let _ = search_field_remaining; + true + } + + fn measure_completion_infos(&mut self) { + let prefix_len = usize::try_from(fish_wcswidth(&self.prefix)); + for comp in &mut self.unfiltered_completion_infos { + let comp_strings = &mut comp.comp; + + for (j, comp_string) in comp_strings.iter().enumerate() { + // If there's more than one, append the length of ', '. + if j >= 1 { + comp.comp_width += 2; + } + + // fish_wcswidth() can return -1 if it can't calculate the width. So be cautious. + let comp_width = fish_wcswidth(comp_string); + if let (Ok(prefix_len), Ok(comp_width)) = (prefix_len, usize::try_from(comp_width)) + { + comp.comp_width += prefix_len + comp_width; + } + } + + // fish_wcswidth() can return -1 if it can't calculate the width. So be cautious. + let desc_width = fish_wcswidth(&comp.desc); + comp.desc_width = usize::try_from(desc_width).unwrap_or_default(); + } + } + + // Indicates if the given completion info passes any filtering we have. + fn completion_info_passes_filter(&self, info: &PagerComp) -> bool { + // If we have no filter, everything passes. + if !self.search_field_shown || self.search_field_line.is_empty() { + return true; + } + + let needle = self.search_field_line.text(); + + // Match against the description. + if string_fuzzy_match_string(needle, &info.desc, false).is_some() { + return true; + } + + // Match against the completion strings. + for candidate in &info.comp { + if string_fuzzy_match_string(needle, &(self.prefix.clone() + &candidate[..]), false) + .is_some() + { + return true; + } + } + false // no match + } + + /// Print the specified part of the completion list, using the specified column offsets and quoting + /// style. + /// + /// \param cols number of columns to print in + /// \param width_by_column An array specifying the width of each column + /// \param row_start The first row to print + /// \param row_stop the row after the last row to print + /// \param prefix The string to print before each completion + /// \param lst The list of completions to print + fn completion_print( + &self, + cols: usize, + width_by_column: &[usize; PAGER_MAX_COLS], + row_start: usize, + row_stop: usize, + prefix: &wstr, + lst: &[PagerComp], + rendering: &mut PageRendering, + ) { + // Teach the rendering about the rows it printed. + assert!(row_stop >= row_start); + rendering.row_start = row_start; + rendering.row_end = row_stop; + + let rows = divide_round_up(lst.len(), cols); + + let effective_selected_idx = self.visual_selected_completion_index(rows, cols); + + for row in row_start..row_stop { + for (col, col_width) in width_by_column.iter().cloned().enumerate() { + let idx = col * rows + row; + if lst.len() <= idx { + continue; + } + + let el = &lst[idx]; + let is_selected = Some(idx) == effective_selected_idx; + + // Print this completion on its own "line". + let mut line = + self.completion_print_item(prefix, el, col_width, row % 2 != 0, is_selected); + + // If there's more to come, append two spaces. + if col + 1 < cols { + line.append_str(PAGER_SPACER_STRING, HighlightSpec::new()); + } + + // Append this to the real line. + rendering + .screen_data + .create_line(row - row_start) + .append_line(&line); + } + } + } + + /// Print the specified item using at the specified amount of space. + fn completion_print_item( + &self, + prefix: &wstr, + c: &PagerComp, + width: usize, + secondary: bool, + selected: bool, + ) -> Line { + let mut comp_width; + let mut line_data = Line::new(); + + if c.preferred_width() <= width { + // The entry fits, we give it as much space as it wants. + comp_width = c.comp_width; + } else { + // The completion and description won't fit on the allocated space. Give a maximum of 2/3 of + // the space to the completion, and whatever is left to the description + // This expression is an overflow-safe way of calculating (width-4)*2/3 + let width_minus_spacer = width.saturating_sub(4); + let two_thirds_width = + (width_minus_spacer / 3) * 2 + ((width_minus_spacer % 3) * 2) / 3; + comp_width = std::cmp::min(c.comp_width, two_thirds_width); + + // If the description is short, give the completion the remaining space + let desc_punct_width = c.description_punctuated_width(); + if width > desc_punct_width { + comp_width = std::cmp::max(comp_width, width - desc_punct_width); + } + + // The description gets what's left + assert!(comp_width <= width); + } + + let modify_role = |mut role: HighlightRole| { + let mut base = role.repr; + if selected { + base += HighlightRole::pager_selected_background.repr + - HighlightRole::pager_background.repr; + } else if secondary { + base += HighlightRole::pager_secondary_background.repr + - HighlightRole::pager_background.repr; + } + role.repr = base; + role + }; + + let bg_role = modify_role(HighlightRole::pager_background); + let bg = HighlightSpec::with_bg(bg_role); + let prefix_col = HighlightSpec::with_fg_bg( + if self.highlight_prefix { + HighlightRole::pager_prefix + } else { + HighlightRole::pager_completion + }, + bg_role, + ); + let comp_col = HighlightSpec::with_fg_bg(HighlightRole::pager_completion, bg_role); + let desc_col = HighlightSpec::with_fg_bg(HighlightRole::pager_description, bg_role); + + // Print the completion part + let mut comp_remaining = comp_width; + for (i, comp) in c.comp.iter().enumerate() { + if i > 0 { + comp_remaining -= print_max( + PAGER_SPACER_STRING, + bg, + comp_remaining, + /*has_more=*/ true, + &mut line_data, + ); + } + + comp_remaining -= print_max( + prefix, + prefix_col, + comp_remaining, + !comp.is_empty(), + &mut line_data, + ); + comp_remaining -= print_max_impl( + comp, + |i| { + if c.colors.is_empty() { + return comp_col; // Not a shell command. + } + if selected { + // Rendered in reverse video, so avoid highlighting. + return comp_col; + } + *c.colors.get(i).unwrap_or(c.colors.last().unwrap()) + }, + comp_remaining, + i + 1 < c.comp.len(), + &mut line_data, + ); + } + + let mut desc_remaining = width - comp_width + comp_remaining; + if c.desc_width > 0 && desc_remaining > 4 { + // always have at least two spaces to separate completion and description + desc_remaining -= print_max(L!(" "), bg, 2, false, &mut line_data); + + // right-justify the description by adding spaces + // the 2 here refers to the parenthesis below + while desc_remaining > c.desc_width + 2 { + desc_remaining -= print_max(L!(" "), bg, 1, false, &mut line_data); + } + + assert!(desc_remaining >= 2); + let paren_col = HighlightSpec::with_fg_bg(HighlightRole::pager_completion, bg_role); + desc_remaining -= print_max(L!("("), paren_col, 1, false, &mut line_data); + desc_remaining -= + print_max(&c.desc, desc_col, desc_remaining - 1, false, &mut line_data); + desc_remaining -= print_max(L!(")"), paren_col, 1, false, &mut line_data); + let _ = desc_remaining; + } else { + // No description, or it won't fit. Just add spaces. + print_max( + &WString::from_iter(std::iter::repeat(' ').take(desc_remaining)), + bg, + desc_remaining, + false, + &mut line_data, + ); + } + + line_data + } + + // Sets the set of completions. + pub fn set_completions(&mut self, raw_completions: &[Completion]) { + self.selected_completion_idx = None; + // Get completion infos out of it. + self.unfiltered_completion_infos = process_completions_into_infos(raw_completions); + + // Maybe join them. + if self.prefix == L!("-") { + join_completions(&mut self.unfiltered_completion_infos); + } + + // Compute their various widths. + self.measure_completion_infos(); + + // Refilter them. + self.refilter_completions(); + self.have_unrendered_completions = true; + } + + // Sets the prefix. + pub fn set_prefix(&mut self, pref: &wstr, highlight: bool /* = true */) { + self.prefix = pref.to_owned(); + self.highlight_prefix = highlight; + } + + // Sets the terminal size. + pub fn set_term_size(&mut self, ts: &Termsize) { + self.available_term_width = usize::try_from(ts.width).unwrap_or_default(); + self.available_term_height = usize::try_from(ts.height).unwrap_or_default(); + } + + // Changes the selected completion in the given direction according to the layout of the given + // rendering. Returns true if the selection changed. + pub fn select_next_completion_in_direction( + &mut self, + direction: SelectionMotion, + rendering: &PageRendering, + ) -> bool { + // Must have something to select. + if self.completion_infos.is_empty() { + return false; + } + + match self.selected_completion_idx { + None => { + // Handle the case of nothing selected yet. + match direction { + SelectionMotion::South + | SelectionMotion::PageSouth + | SelectionMotion::Next + | SelectionMotion::North + | SelectionMotion::Prev => { + // These directions do something sane. + if matches!(direction, SelectionMotion::Prev | SelectionMotion::North) { + self.selected_completion_idx = Some(self.completion_infos.len() - 1); + } else { + self.selected_completion_idx = Some(0); + } + } + SelectionMotion::East + | SelectionMotion::West + | SelectionMotion::PageNorth + | SelectionMotion::Deselect => { + // These do nothing. + return false; + } + } + } + Some(selected_completion_idx) => { + // Ok, we had something selected already. Select something different. + let new_selected_completion_idx; + if !selection_direction_is_cardinal(direction) { + // Next, previous, or deselect, all easy. + if direction == SelectionMotion::Deselect { + new_selected_completion_idx = None; + } else if direction == SelectionMotion::Next { + let mut new_idx = selected_completion_idx + 1; + if new_idx >= self.completion_infos.len() { + new_idx = 0; + } + new_selected_completion_idx = Some(new_idx); + } else if direction == SelectionMotion::Prev { + if selected_completion_idx == 0 { + new_selected_completion_idx = Some(self.completion_infos.len() - 1); + } else { + new_selected_completion_idx = if selected_completion_idx != 0 { + Some(selected_completion_idx - 1) + } else { + None + }; + } + } else { + unreachable!("unknown non-cardinal direction"); + } + } else { + // Cardinal directions. We have a completion index; we wish to compute its row and + // column. + let mut current_row = self + .get_selected_row(rendering) + .unwrap_or(PAGER_SELECTION_NONE); + let mut current_col = self + .get_selected_column(rendering) + .unwrap_or(PAGER_SELECTION_NONE); + let page_height = std::cmp::max(rendering.term_height.unwrap() - 1, 1); + + match direction { + SelectionMotion::PageNorth => { + if current_row > page_height { + current_row -= page_height; + } else { + current_row = 0; + } + } + SelectionMotion::North => { + // Go up a whole row. If we cycle, go to the previous column. + if current_row > 0 { + current_row -= 1; + } else { + current_row = rendering.rows - 1; + if current_col > 0 { + current_col -= 1; + } else { + current_col = rendering.cols - 1; + } + } + } + SelectionMotion::PageSouth => { + if current_row + page_height < rendering.rows { + current_row += page_height; + } else { + current_row = rendering.rows - 1; + if current_col * rendering.rows + current_row + >= self.completion_infos.len() + { + current_row = + (self.completion_infos.len() - 1) % rendering.rows; + } + } + } + SelectionMotion::South => { + // Go down, unless we are in the last row. + // If we go over the last element, wrap to the first. + if current_row + 1 < rendering.rows + && current_col * rendering.rows + current_row + 1 + < self.completion_infos.len() + { + current_row += 1; + } else { + current_row = 0; + current_col = (current_col + 1) % rendering.cols; + } + } + SelectionMotion::East => { + // Go east, wrapping to the next row. There is no "row memory," so if we run off + // the end, wrap. + if current_col + 1 < rendering.cols + && (current_col + 1) * rendering.rows + current_row + < self.completion_infos.len() + { + current_col += 1; + } else { + current_col = 0; + current_row = (current_row + 1) % rendering.rows; + } + } + SelectionMotion::West => { + // Go west, wrapping to the previous row. + if current_col > 0 { + current_col -= 1; + } else { + current_col = rendering.cols - 1; + if current_row > 0 { + current_row -= 1; + } else { + current_row = rendering.rows - 1; + } + } + } + SelectionMotion::Next + | SelectionMotion::Prev + | SelectionMotion::Deselect => (), + } + + // Compute the new index based on the changed row. + new_selected_completion_idx = Some(current_col * rendering.rows + current_row); + } + + if Some(selected_completion_idx) == new_selected_completion_idx { + return false; + } + self.selected_completion_idx = new_selected_completion_idx; + } + } + + // Update suggested_row_start to ensure the selection is visible. suggested_row_start * + // rendering.cols is the first suggested visible completion; add the visible completion + // count to that to get the last one. + let visible_row_count = rendering.row_end - rendering.row_start; + if visible_row_count == 0 { + return true; // this happens if there was no room to draw the pager + } + if self.selected_completion_idx.is_none() { + return true; + } + + // Ensure our suggested row start is not past the selected row. + let row_containing_selection = self + .get_selected_row_given_rows(rendering.rows) + .unwrap_or(PAGER_SELECTION_NONE); + if self.suggested_row_start > row_containing_selection { + self.suggested_row_start = row_containing_selection; + } + + // Ensure our suggested row start is not too early before it. + if self.suggested_row_start + visible_row_count <= row_containing_selection { + // The user moved south past the bottom completion. + if !self.fully_disclosed && rendering.remaining_to_disclose > 0 { + self.fully_disclosed = true; // perform disclosure + } else { + // Scroll + self.suggested_row_start = row_containing_selection - visible_row_count + 1; + // Ensure fully_disclosed is set. I think we can hit this case if the user + // resizes the window - we don't want to drop back to the disclosed style. + self.fully_disclosed = true; + } + } + + true + } + + // Returns the currently selected completion for the given rendering. + pub fn selected_completion(&'_ self, rendering: &PageRendering) -> Option<&'_ Completion> { + self.visual_selected_completion_index(rendering.rows, rendering.cols) + .map(|idx| &self.completion_infos[idx].representative) + } + + pub fn selected_completion_index(&self) -> Option { + self.selected_completion_idx + } + pub fn set_selected_completion_index(&mut self, mut new_index: Option) { + // Current users are off by one at most. + assert!(new_index.is_none_or(|new_index| new_index <= self.completion_infos.len())); + if new_index == Some(self.completion_infos.len()) { + new_index = Some(self.completion_infos.len() - 1); + } + self.selected_completion_idx = new_index; + } + + // Indicates the row and column for the given rendering. Returns -1 if no selection. + pub fn get_selected_row(&self, rendering: &PageRendering) -> Option { + if rendering.rows == 0 { + return None; + } + + rendering + .selected_completion_idx + .map(|idx| idx % rendering.rows) + } + pub fn get_selected_column(&self, rendering: &PageRendering) -> Option { + if rendering.rows == 0 { + return None; + } + rendering + .selected_completion_idx + .map(|idx| idx / rendering.rows) + } + // Indicates the row assuming we render this many rows. Returns -1 if no selection. + pub fn get_selected_row_given_rows(&self, rows: usize) -> Option { + if rows == 0 { + return None; + }; + + self.selected_completion_idx.map(|idx| idx % rows) + } + + // Produces a rendering of the completions, at the given term size. + pub fn render(&self) -> PageRendering { + // Try to print the completions. Start by trying to print the list in PAGER_MAX_COLS columns, + // if the completions won't fit, reduce the number of columns by one. Printing a single column + // never fails. + let mut rendering = PageRendering::new(); + rendering.term_width = Some(self.available_term_width); + rendering.term_height = Some(self.available_term_height); + rendering.search_field_line = self.search_field_line.clone(); + for cols in (1..=PAGER_MAX_COLS).rev() { + // Initially empty rendering. + rendering.screen_data.resize(0); + + // Determine how many rows we would need if we had 'cols' columns. Then determine how many + // columns we want from that. For example, say we had 19 completions. We can fit them into 6 + // columns, 4 rows, with the last row containing only 1 entry. Or we can fit them into 5 + // columns, 4 rows, the last row containing 4 entries. Since fewer columns with the same + // number of rows is better, skip cases where we know we can do better. + let min_rows_required_for_cols = divide_round_up(self.completion_infos.len(), cols); + let min_cols_required_for_rows = + divide_round_up(self.completion_infos.len(), min_rows_required_for_cols); + + assert!(min_cols_required_for_rows <= cols); + if cols > 1 && min_cols_required_for_rows < cols { + // Next iteration will be better, so skip this one. + continue; + } + + rendering.cols = cols; + rendering.rows = min_rows_required_for_cols; + rendering.selected_completion_idx = + self.visual_selected_completion_index(rendering.rows, rendering.cols); + + if self.completion_try_print( + cols, + &self.prefix, + &self.completion_infos, + &mut rendering, + self.suggested_row_start, + ) { + break; + } + } + rendering + } + + // \return true if the given rendering needs to be updated. + pub fn rendering_needs_update(&self, rendering: &PageRendering) -> bool { + if self.have_unrendered_completions { + return true; + } + // Common case is no pager. + if self.is_empty() && rendering.screen_data.is_empty() { + return false; + } + + (self.is_empty() && !rendering.screen_data.is_empty()) || // Do update after clear(). + rendering.term_width != Some(self.available_term_width) || + rendering.term_height != Some(self.available_term_height) || + rendering.selected_completion_idx != + self.visual_selected_completion_index(rendering.rows, rendering.cols) || + rendering.search_field_shown != self.search_field_shown || + *rendering.search_field_line.text() != *self.search_field_line.text() || + rendering.search_field_line.position() != self.search_field_line.position() || + (rendering.remaining_to_disclose > 0 && self.fully_disclosed) + } + + // Updates the rendering. + pub fn update_rendering(&mut self, rendering: &mut PageRendering) { + if self.rendering_needs_update(rendering) { + *rendering = self.render(); + self.have_unrendered_completions = false; + } + } + + // Indicates if there are no completions, and therefore nothing to render. + pub fn is_empty(&self) -> bool { + self.unfiltered_completion_infos.is_empty() + } + + // Clears all completions and the prefix. + pub fn clear(&mut self) { + self.unfiltered_completion_infos.clear(); + self.completion_infos.clear(); + self.prefix.clear(); + self.highlight_prefix = false; + self.selected_completion_idx = None; + self.fully_disclosed = false; + self.search_field_shown = false; + self.search_field_line.clear(); + self.extra_progress_text.clear(); + } + + // Updates the completions list per the filter. + pub fn refilter_completions(&mut self) { + self.completion_infos.clear(); + for i in 0..self.unfiltered_completion_infos.len() { + if self.completion_info_passes_filter(&self.unfiltered_completion_infos[i]) { + self.completion_infos + .push(self.unfiltered_completion_infos[i].clone()); + } + } + } + + // Sets whether the search field is shown. + pub fn set_search_field_shown(&mut self, flag: bool) { + self.search_field_shown = flag; + } + + // Gets whether the search field shown. + pub fn is_search_field_shown(&self) -> bool { + self.search_field_shown + } + + // Indicates if we are navigating our contents. + // It's possible we have no visual selection but are still navigating the contents, e.g. every + // completion is filtered. + pub fn is_navigating_contents(&self) -> bool { + self.selected_completion_idx.is_some() + } + + // Become fully disclosed. + pub fn set_fully_disclosed(&mut self) { + self.fully_disclosed = true; + } + + // Position of the cursor. + pub fn cursor_position(&self) -> usize { + let mut result = SEARCH_FIELD_PROMPT.len() + self.search_field_line.position(); + // Clamp it to the right edge. + if self.available_term_width > 0 && result + 1 > self.available_term_width { + result = self.available_term_width - 1; + } + result + } +} + +/// Data structure describing one or a group of related completions. +#[derive(Clone, Default)] +pub struct PagerComp { + /// The list of all completion strings this entry applies to. + pub comp: Vec, + /// The description. + pub desc: WString, + /// The representative completion. + pub representative: Completion, + /// The per-character highlighting, used when this is a full shell command. + pub colors: Vec, + /// On-screen width of the completion string. + pub comp_width: usize, + /// On-screen width of the description information. + pub desc_width: usize, +} + +impl PagerComp { + // Our text looks like this: + // completion (description) + // Two spaces separating, plus parens, yields 4 total extra space + // but only if we have a description of course + pub fn description_punctuated_width(&self) -> usize { + self.desc_width + if self.desc_width != 0 { 4 } else { 0 } + } + + // Returns the preferred width, containing the sum of the + // width of the completion, separator, description + pub fn preferred_width(&self) -> usize { + self.comp_width + self.description_punctuated_width() + } +} + +fn selection_direction_is_cardinal(dir: SelectionMotion) -> bool { + match dir { + SelectionMotion::North + | SelectionMotion::East + | SelectionMotion::South + | SelectionMotion::West + | SelectionMotion::PageNorth + | SelectionMotion::PageSouth => true, + SelectionMotion::Next | SelectionMotion::Prev | SelectionMotion::Deselect => false, + } +} + +/// Returns numer / denom, rounding up. As a "courtesy" 0/0 is 0. +fn divide_round_up(numer: usize, denom: usize) -> usize { + if numer == 0 { + return 0; + } + assert!(denom != 0); + let has_rem = (numer % denom) != 0; + numer / denom + if has_rem { 1 } else { 0 } +} + +/// Print the specified string, but use at most the specified amount of space. If the whole string +/// can't be fitted, ellipsize it. +/// +/// \param str the string to print +/// \param color the color to apply to every printed character +/// \param max the maximum space that may be used for printing +/// \param has_more if this flag is true, this is not the entire string, and the string should be +/// ellipsized even if the string fits but takes up the whole space. +fn print_max_impl( + s: &wstr, + color: impl Fn(usize) -> HighlightSpec, + max: usize, + has_more: bool, + line: &mut Line, +) -> usize { + let mut remaining = max; + for (i, c) in s.chars().enumerate() { + let iwidth_c = fish_wcwidth(c); + let Ok(width_c) = usize::try_from(iwidth_c) else { + // skip non-printable characters + continue; + }; + + if width_c > remaining { + break; + } + + let ellipsis = get_ellipsis_char(); + if (width_c == remaining) && (has_more || i + 1 < s.len()) { + line.append(ellipsis, color(i)); + let ellipsis_width = fish_wcwidth(ellipsis); + remaining = remaining.saturating_sub(usize::try_from(ellipsis_width).unwrap()); + break; + } + + line.append(c, color(i)); + remaining = remaining.checked_sub(width_c).unwrap(); + } + + // return how much we consumed + max.checked_sub(remaining).unwrap() +} + +fn print_max(s: &wstr, color: HighlightSpec, max: usize, has_more: bool, line: &mut Line) -> usize { + print_max_impl(s, |_| color, max, has_more, line) +} + +/// Trim leading and trailing whitespace, and compress other whitespace runs into a single space. +fn mangle_1_completion_description(s: &mut WString) { + let mut leading = 0; + let mut trailing = 0; + let len = s.len(); + + // Skip leading spaces. + while leading < len { + if !s.char_at(leading).is_whitespace() { + break; + } + leading += 1; + } + + // Compress runs of spaces to a single space. + let mut was_space = false; + while leading < s.len() { + let wc = s.char_at(leading); + let is_space = wc.is_whitespace(); + if !is_space { + // normal character + s.as_char_slice_mut()[trailing] = wc; + trailing += 1; + } else if !was_space { + // initial space in a run + s.as_char_slice_mut()[trailing] = ' '; + trailing += 1; + } // else non-initial space in a run, do nothing + was_space = is_space; + leading += 1; + } + + // leading is now at len, trailing is the new length of the string. Delete trailing spaces. + while trailing > 0 && s.char_at(trailing - 1).is_whitespace() { + trailing -= 1; + } + + s.truncate(trailing); +} + +fn join_completions(comps: &mut Vec) { + // A map from description to index in the completion list of the element with that description. + // The indexes are stored +1. + let mut desc_table: HashMap = HashMap::new(); + + // Note that we mutate the completion list as we go, so the size changes. + let mut i = 0; + while i < comps.len() { + if comps[i].desc.is_empty() { + i += 1; + continue; + } + + // See if it's in the table. + match desc_table.entry(comps[i].desc.clone()) { + Entry::Vacant(entry) => { + // We're the first with this description. + entry.insert(i + 1); + i += 1; + } + Entry::Occupied(entry) => { + // There's a prior completion with this description. Append the new ones to it. + let prev_idx_plus_one = entry.get(); + // Erase the element at this index. + let new_comp = comps.remove(i); + let prior_comp = &mut comps[prev_idx_plus_one - 1]; + prior_comp.comp.extend(new_comp.comp); + } + } + } +} + +/// Generate a list of comp_t structures from a list of completions. +fn process_completions_into_infos(lst: &[Completion]) -> Vec { + // Make the list of the correct size up-front. + let mut result = Vec::with_capacity(lst.len()); + for (i, comp) in lst.iter().enumerate() { + result.push(PagerComp::default()); + let comp_info = &mut result[i]; + + // Append the single completion string. We may later merge these into multiple. + comp_info.comp.push(escape_string( + &comp.completion, + EscapeStringStyle::Script( + EscapeFlags::NO_PRINTABLES | EscapeFlags::NO_QUOTED | EscapeFlags::SYMBOLIC, + ), + )); + if comp.replaces_commandline() + // HACK We want to render a full shell command, with syntax highlighting. Above we + // escape nonprintables, which might make the rendered command longer than the original + // completion. In that case we get wrong colors. However this should only happen in + // contrived cases, since our symbolic escaping uses a single character to represent + // newline and tab characters; other nonprintables are extremely rare in a command + // line. It will only be common for single-byte locales where we don't + // use Unicode characters for escaping, so just disable those here. + // We should probably fix this by first highlighting the original completion, and + // then writing a variant of escape_string() that adjusts highlighting according + // so it matches the escaped string. + && MB_CUR_MAX() > 1 + { + highlight_shell( + &comp.completion, + &mut comp_info.colors, + &OperationContext::empty(), + false, + None, + ); + assert!(comp_info.comp.last().unwrap().len() >= comp_info.colors.len()); + } + + // Append the mangled description. + comp_info.desc = comp.description.clone(); + mangle_1_completion_description(&mut comp_info.desc); + + // Set the representative completion. + comp_info.representative = comp.clone(); + } + result +} + +#[cxx::bridge] +mod pager_ffi { + extern "C++" { + include!("complete.h"); + include!("editable_line.h"); + type CompletionListFfi = crate::complete::CompletionListFfi; + type Completion = crate::complete::Completion; + type EditableLine = crate::editable_line::EditableLine; + } + enum selection_motion_t { + north, + east, + south, + west, + page_north, + page_south, + next, + prev, + deselect, + } + extern "Rust" { + type PageRendering; + fn new_page_rendering() -> Box; + fn remaining_to_disclose(&self) -> usize; + } + extern "Rust" { + type Pager; + fn new_pager() -> Box; + fn clear(&mut self); + fn cursor_position(&self) -> usize; + fn empty(&self) -> bool; + fn extra_progress_text(&self) -> UniquePtr; + fn set_extra_progress_text(&mut self, text: &CxxWString); + fn is_navigating_contents(&self) -> bool; + fn is_search_field_shown(&self) -> bool; + fn refilter_completions(&mut self); + fn rendering_needs_update(&self, rendering: &PageRendering) -> bool; + fn search_field_line(&mut self) -> *mut EditableLine; + #[cxx_name = "set_completions"] + fn set_completions_ffi(&mut self, completions: &CompletionListFfi); + fn set_fully_disclosed(&mut self); + #[cxx_name = "set_prefix"] + fn set_prefix_ffi(&mut self, prefix: &CxxWString, highlight: bool); + fn set_search_field_shown(&mut self, flag: bool); + #[cxx_name = "selected_completion"] + fn selected_completion_ffi(&self, rendering: &PageRendering) -> *const Completion; + #[cxx_name = "get_selected_row"] + fn get_selected_row_ffi(&self, rendering: &PageRendering) -> usize; + #[cxx_name = "get_selected_column"] + fn get_selected_column_ffi(&self, rendering: &PageRendering) -> usize; + #[cxx_name = "select_next_completion_in_direction"] + fn select_next_completion_in_direction_ffi( + &mut self, + direction: selection_motion_t, + rendering: &PageRendering, + ) -> bool; + #[cxx_name = "selected_completion_index"] + fn selected_completion_index_ffi(&self) -> usize; + #[cxx_name = "set_selected_completion_index"] + fn set_selected_completion_index_ffi(&mut self, new_index: usize); + } +} +fn new_page_rendering() -> Box { + Box::default() +} +impl PageRendering { + fn remaining_to_disclose(&self) -> usize { + self.remaining_to_disclose + } +} +fn new_pager() -> Box { + Box::default() +} +impl Pager { + fn select_next_completion_in_direction_ffi( + &mut self, + direction: selection_motion_t, + rendering: &PageRendering, + ) -> bool { + self.select_next_completion_in_direction(direction.from_ffi(), rendering) + } + fn selected_completion_ffi(&self, rendering: &PageRendering) -> *const Completion { + match self.selected_completion(rendering) { + Some(completion) => completion as *const Completion, + None => std::ptr::null(), + } + } + fn set_prefix_ffi(&mut self, prefix: &CxxWString, highlight: bool) { + self.set_prefix(prefix.as_wstr(), highlight); + } + fn search_field_line(&mut self) -> *mut EditableLine { + &mut self.search_field_line as *mut EditableLine + } + fn empty(&self) -> bool { + self.is_empty() + } + fn extra_progress_text(&self) -> UniquePtr { + self.extra_progress_text.to_ffi() + } + fn set_extra_progress_text(&mut self, text: &CxxWString) { + self.extra_progress_text = text.from_ffi(); + } + fn set_completions_ffi(&mut self, completions: &CompletionListFfi) { + self.set_completions(&completions.0) + } + fn selected_completion_index_ffi(&self) -> usize { + self.selected_completion_idx.unwrap_or(PAGER_SELECTION_NONE) + } + fn set_selected_completion_index_ffi(&mut self, new_index: usize) { + self.set_selected_completion_index(if new_index == PAGER_SELECTION_NONE { + None + } else { + Some(new_index) + }); + } + fn get_selected_row_ffi(&self, rendering: &PageRendering) -> usize { + self.get_selected_row(rendering) + .unwrap_or(PAGER_SELECTION_NONE) + } + fn get_selected_column_ffi(&self, rendering: &PageRendering) -> usize { + self.get_selected_column(rendering) + .unwrap_or(PAGER_SELECTION_NONE) + } +} +use pager_ffi::selection_motion_t; +impl selection_motion_t { + #[allow(clippy::wrong_self_convention)] + fn from_ffi(self) -> SelectionMotion { + match self { + selection_motion_t::north => SelectionMotion::North, + selection_motion_t::east => SelectionMotion::East, + selection_motion_t::south => SelectionMotion::South, + selection_motion_t::west => SelectionMotion::West, + selection_motion_t::page_north => SelectionMotion::PageNorth, + selection_motion_t::page_south => SelectionMotion::PageSouth, + selection_motion_t::next => SelectionMotion::Next, + selection_motion_t::prev => SelectionMotion::Prev, + selection_motion_t::deselect => SelectionMotion::Deselect, + _ => unreachable!(), + } + } +} +unsafe impl cxx::ExternType for Pager { + type Id = cxx::type_id!("Pager"); + type Kind = cxx::kind::Opaque; +} +unsafe impl cxx::ExternType for PageRendering { + type Id = cxx::type_id!("PageRendering"); + type Kind = cxx::kind::Opaque; +} diff --git a/fish-rust/src/screen.rs b/fish-rust/src/screen.rs index 19f630f12..8bda4b6ed 100644 --- a/fish-rust/src/screen.rs +++ b/fish-rust/src/screen.rs @@ -7,9 +7,11 @@ //! The current implementation is less smart than ncurses allows and can not for example move blocks //! of text around to handle text insertion. +use crate::pager::{PageRendering, Pager}; use std::collections::LinkedList; use std::ffi::{CStr, CString}; use std::io::Write; +use std::pin::Pin; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Mutex; @@ -24,7 +26,6 @@ use crate::curses::{term, tparm0, tparm1}; use crate::env::{EnvStackRef, Environment, TERM_HAS_XN}; use crate::fallback::fish_wcwidth; -use crate::ffi::{self, Repin}; use crate::flog::FLOGF; use crate::future::IsSomeAnd; use crate::global_safety::RelaxedAtomicBool; @@ -35,7 +36,6 @@ use crate::wchar_ffi::{AsWstr, WCharFromFFI, WCharToFFI}; use crate::wcstringutil::string_prefixes_string; use crate::{highlight::HighlightSpec, wcstringutil::fish_wcwidth_visible}; -use std::pin::Pin; #[derive(Clone, Default)] pub struct HighlightedChar { @@ -53,7 +53,7 @@ pub struct Line { } impl Line { - fn new() -> Self { + pub fn new() -> Self { Default::default() } @@ -148,10 +148,11 @@ pub fn resize(&mut self, size: usize) { self.line_datas.resize(size, Default::default()) } - pub fn create_line(&mut self, idx: usize) { + pub fn create_line(&mut self, idx: usize) -> &mut Line { if idx >= self.line_datas.len() { self.line_datas.resize(idx + 1, Default::default()) } + self.line_mut(idx) } pub fn insert_line_at_index(&mut self, idx: usize) -> &mut Line { @@ -256,8 +257,8 @@ pub fn write( indent: &[usize], cursor_pos: usize, vars: &dyn Environment, - pager: Pin<&mut ffi::pager_t>, - page_rendering: Pin<&mut ffi::page_rendering_t>, + pager: &mut Pager, + page_rendering: &mut PageRendering, cursor_is_within_pager: bool, ) { let curr_termsize = termsize_last(); @@ -362,25 +363,19 @@ pub fn write( // Re-render our completions page if necessary. Limit the term size of the pager to the true // term size, minus the number of lines consumed by our string. - let pager = pager.unpin(); - let page_rendering = page_rendering.unpin(); - crate::ffi::pager_set_term_size_ffi( - pager.pin(), - &Termsize::new( - std::cmp::max(1, curr_termsize.width), - std::cmp::max( - 1, - curr_termsize - .height - .saturating_sub_unsigned(full_line_count), - ), - ) as *const Termsize as *const autocxx::c_void, - ); + pager.set_term_size(&Termsize::new( + std::cmp::max(1, curr_termsize.width), + std::cmp::max( + 1, + curr_termsize + .height + .saturating_sub_unsigned(full_line_count), + ), + )); - crate::ffi::pager_update_rendering_ffi(pager.pin(), page_rendering.pin()); + pager.update_rendering(page_rendering); // Append pager_data (none if empty). - self.desired - .append_lines(unsafe { &*(page_rendering.screen_data_ffi() as *const ScreenData) }); + self.desired.append_lines(&page_rendering.screen_data); self.update(&layout.left_prompt, &layout.right_prompt, vars); self.save_status(); @@ -1848,11 +1843,14 @@ fn compute_layout( mod screen_ffi { extern "C++" { include!("screen.h"); + include!("pager.h"); include!("highlight.h"); pub type HighlightSpec = crate::highlight::HighlightSpec; pub type HighlightSpecListFFI = crate::highlight::HighlightSpecListFFI; - pub type pager_t = crate::ffi::pager_t; - pub type page_rendering_t = crate::ffi::page_rendering_t; + // pub type pager_t = crate::ffi::pager_t; + // pub type page_rendering_t = crate::ffi::page_rendering_t; + pub type Pager = crate::pager::Pager; + pub type PageRendering = crate::pager::PageRendering; pub type highlight_spec_t = crate::ffi::highlight_spec_t; } extern "Rust" { @@ -1891,8 +1889,8 @@ fn write_ffi( indent: &CxxVector, cursor_pos: usize, vars: *mut u8, - pager: Pin<&mut pager_t>, - page_rendering: Pin<&mut page_rendering_t>, + pager: Pin<&mut Pager>, + page_rendering: Pin<&mut PageRendering>, cursor_is_within_pager: bool, ); fn reset_abandoning_line(&mut self, screen_width: usize); @@ -1956,8 +1954,8 @@ fn write_ffi( indent: &CxxVector, cursor_pos: usize, vars: *mut u8, - pager: Pin<&mut ffi::pager_t>, - page_rendering: Pin<&mut ffi::page_rendering_t>, + pager: Pin<&mut Pager>, + page_rendering: Pin<&mut PageRendering>, cursor_is_within_pager: bool, ) { let vars = unsafe { Box::from_raw(vars as *mut EnvStackRef) }; @@ -1974,8 +1972,8 @@ fn write_ffi( &my_indent, cursor_pos, vars.as_ref().as_ref().get_ref(), - pager, - page_rendering, + pager.get_mut(), + page_rendering.get_mut(), cursor_is_within_pager, ); } diff --git a/fish-rust/src/tests/mod.rs b/fish-rust/src/tests/mod.rs index 2b871d78a..dc33f2df5 100644 --- a/fish-rust/src/tests/mod.rs +++ b/fish-rust/src/tests/mod.rs @@ -11,6 +11,7 @@ mod fd_monitor; mod highlight; mod history; +mod pager; mod parser; #[cfg(test)] mod redirection; diff --git a/fish-rust/src/tests/pager.rs b/fish-rust/src/tests/pager.rs new file mode 100644 index 000000000..080e065d7 --- /dev/null +++ b/fish-rust/src/tests/pager.rs @@ -0,0 +1,192 @@ +use crate::common::get_ellipsis_char; +use crate::complete::{CompleteFlags, Completion}; +use crate::ffi_tests::add_test; +use crate::pager::{Pager, SelectionMotion}; +use crate::termsize::Termsize; +use crate::wchar::prelude::*; +use crate::wchar_ext::WExt; +use crate::wcstringutil::StringFuzzyMatch; + +add_test!("test_pager_navigation", || { + // Generate 19 strings of width 10. There's 2 spaces between completions, and our term size is + // 80; these can therefore fit into 6 columns (6 * 12 - 2 = 70) or 5 columns (58) but not 7 + // columns (7 * 12 - 2 = 82). + // + // You can simulate this test by creating 19 files named "file00.txt" through "file_18.txt". + let mut completions = vec![]; + for _ in 0..19 { + completions.push(Completion::new( + L!("abcdefghij").to_owned(), + "".into(), + StringFuzzyMatch::exact_match(), + CompleteFlags::default(), + )); + } + + let mut pager = Pager::default(); + pager.set_completions(&completions); + pager.set_term_size(&Termsize::defaults()); + let mut render = pager.render(); + + assert_eq!(render.term_width, Some(80)); + assert_eq!(render.term_height, Some(24)); + + let rows = 4; + let cols = 5; + + // We have 19 completions. We can fit into 6 columns with 4 rows or 5 columns with 4 rows; the + // second one is better and so is what we ought to have picked. + assert_eq!(render.rows, rows); + assert_eq!(render.cols, cols); + + // Initially expect to have no completion index. + assert!(render.selected_completion_idx.is_none()); + + // Here are navigation directions and where we expect the selection to be. + macro_rules! validate { + ($pager:ident, $render:ident, $dir:expr, $sel:expr) => { + $pager.select_next_completion_in_direction($dir, &$render); + $pager.update_rendering(&mut $render); + assert_eq!( + Some($sel), + $render.selected_completion_idx, + "For command {:?}", + $dir + ); + }; + } + + // Tab completion to get into the list. + validate!(pager, render, SelectionMotion::Next, 0); + // Westward motion in upper left goes to the last filled column in the last row. + validate!(pager, render, SelectionMotion::West, 15); + // East goes back. + validate!(pager, render, SelectionMotion::East, 0); + validate!(pager, render, SelectionMotion::West, 15); + validate!(pager, render, SelectionMotion::West, 11); + validate!(pager, render, SelectionMotion::East, 15); + validate!(pager, render, SelectionMotion::East, 0); + // "Next" motion goes down the column. + validate!(pager, render, SelectionMotion::Next, 1); + validate!(pager, render, SelectionMotion::Next, 2); + validate!(pager, render, SelectionMotion::West, 17); + validate!(pager, render, SelectionMotion::East, 2); + validate!(pager, render, SelectionMotion::East, 6); + validate!(pager, render, SelectionMotion::East, 10); + validate!(pager, render, SelectionMotion::East, 14); + validate!(pager, render, SelectionMotion::East, 18); + validate!(pager, render, SelectionMotion::West, 14); + validate!(pager, render, SelectionMotion::East, 18); + // Eastward motion wraps back to the upper left, westward goes to the prior column. + validate!(pager, render, SelectionMotion::East, 3); + validate!(pager, render, SelectionMotion::East, 7); + validate!(pager, render, SelectionMotion::East, 11); + validate!(pager, render, SelectionMotion::East, 15); + // Pages. + validate!(pager, render, SelectionMotion::PageNorth, 12); + validate!(pager, render, SelectionMotion::PageSouth, 15); + validate!(pager, render, SelectionMotion::PageNorth, 12); + validate!(pager, render, SelectionMotion::East, 16); + validate!(pager, render, SelectionMotion::PageSouth, 18); + validate!(pager, render, SelectionMotion::East, 3); + validate!(pager, render, SelectionMotion::North, 2); + validate!(pager, render, SelectionMotion::PageNorth, 0); + validate!(pager, render, SelectionMotion::PageSouth, 3); +}); + +add_test!("test_pager_layout", || { + // These tests are woefully incomplete + // They only test the truncation logic for a single completion + + let rendered_line = |pager: &mut Pager, width: isize| { + pager.set_term_size(&Termsize::new(width, 24)); + let rendering = pager.render(); + let sd = &rendering.screen_data; + assert_eq!(sd.line_count(), 1); + let line = sd.line(0); + WString::from(Vec::from_iter((0..line.len()).map(|i| line.char_at(i)))) + }; + let compute_expected = |expected: &wstr| { + let ellipsis_char = get_ellipsis_char(); + if ellipsis_char != '\u{2026}' { + // hack: handle the case where ellipsis is not L'\x2026' + expected.replace(L!("\u{2026}"), wstr::from_char_slice(&[ellipsis_char])) + } else { + expected.to_owned() + } + }; + + macro_rules! validate { + ($pager:expr, $width:expr, $expected:expr) => { + assert_eq!( + rendered_line($pager, $width), + compute_expected($expected), + "width {}", + $width + ); + }; + } + + let mut pager = Pager::default(); + + // These test cases have equal completions and descriptions + let c1s = vec![Completion::new( + L!("abcdefghij").to_owned(), + L!("1234567890").to_owned(), + StringFuzzyMatch::exact_match(), + CompleteFlags::default(), + )]; + pager.set_completions(&c1s); + + validate!(&mut pager, 26, L!("abcdefghij (1234567890)")); + validate!(&mut pager, 25, L!("abcdefghij (1234567890)")); + validate!(&mut pager, 24, L!("abcdefghij (1234567890)")); + validate!(&mut pager, 23, L!("abcdefghij (12345678…)")); + validate!(&mut pager, 22, L!("abcdefghij (1234567…)")); + validate!(&mut pager, 21, L!("abcdefghij (123456…)")); + validate!(&mut pager, 20, L!("abcdefghij (12345…)")); + validate!(&mut pager, 19, L!("abcdefghij (1234…)")); + validate!(&mut pager, 18, L!("abcdefgh… (1234…)")); + validate!(&mut pager, 17, L!("abcdefg… (1234…)")); + validate!(&mut pager, 16, L!("abcdefg… (123…)")); + + // These test cases have heavyweight completions + let c2s = vec![Completion::new( + L!("abcdefghijklmnopqrs").to_owned(), + L!("1").to_owned(), + StringFuzzyMatch::exact_match(), + CompleteFlags::default(), + )]; + pager.set_completions(&c2s); + validate!(&mut pager, 26, L!("abcdefghijklmnopqrs (1)")); + validate!(&mut pager, 25, L!("abcdefghijklmnopqrs (1)")); + validate!(&mut pager, 24, L!("abcdefghijklmnopqrs (1)")); + validate!(&mut pager, 23, L!("abcdefghijklmnopq… (1)")); + validate!(&mut pager, 22, L!("abcdefghijklmnop… (1)")); + validate!(&mut pager, 21, L!("abcdefghijklmno… (1)")); + validate!(&mut pager, 20, L!("abcdefghijklmn… (1)")); + validate!(&mut pager, 19, L!("abcdefghijklm… (1)")); + validate!(&mut pager, 18, L!("abcdefghijkl… (1)")); + validate!(&mut pager, 17, L!("abcdefghijk… (1)")); + validate!(&mut pager, 16, L!("abcdefghij… (1)")); + + // These test cases have no descriptions + let c3s = vec![Completion::new( + L!("abcdefghijklmnopqrst").to_owned(), + L!("").to_owned(), + StringFuzzyMatch::exact_match(), + CompleteFlags::default(), + )]; + pager.set_completions(&c3s); + validate!(&mut pager, 26, L!("abcdefghijklmnopqrst")); + validate!(&mut pager, 25, L!("abcdefghijklmnopqrst")); + validate!(&mut pager, 24, L!("abcdefghijklmnopqrst")); + validate!(&mut pager, 23, L!("abcdefghijklmnopqrst")); + validate!(&mut pager, 22, L!("abcdefghijklmnopqrst")); + validate!(&mut pager, 21, L!("abcdefghijklmnopqrst")); + validate!(&mut pager, 20, L!("abcdefghijklmnopqrst")); + validate!(&mut pager, 19, L!("abcdefghijklmnopqr…")); + validate!(&mut pager, 18, L!("abcdefghijklmnopq…")); + validate!(&mut pager, 17, L!("abcdefghijklmnop…")); + validate!(&mut pager, 16, L!("abcdefghijklmno…")); +}); diff --git a/fish-rust/src/wchar_ext.rs b/fish-rust/src/wchar_ext.rs index f75a2370f..7c0b2fd50 100644 --- a/fish-rust/src/wchar_ext.rs +++ b/fish-rust/src/wchar_ext.rs @@ -1,6 +1,9 @@ use std::{iter, slice}; -use crate::wchar::{wstr, WString}; +use crate::{ + common::subslice_position, + wchar::{wstr, WString}, +}; use widestring::utfstr::CharsUtf32; /// Helpers to convert things to widestring. @@ -237,6 +240,19 @@ fn inner(lhs: &[char], rhs: &[char]) -> Option { inner(self.as_char_slice(), search.as_ref()) } + /// Replaces all matches of a pattern with another string. + fn replace(&self, from: impl AsRef<[char]>, to: &wstr) -> WString { + let from = from.as_ref(); + let mut s = self.as_char_slice().to_vec(); + let mut offset = 0; + while let Some(relpos) = subslice_position(&s[offset..], from) { + offset += relpos; + s.splice(offset..(offset + from.len()), to.chars()); + offset += to.len(); + } + WString::from_chars(s) + } + /// \return the index of the first occurrence of the given char, or None. fn find_char(&self, c: char) -> Option { self.as_char_slice().iter().position(|&x| x == c) diff --git a/fish-rust/src/wcstringutil.rs b/fish-rust/src/wcstringutil.rs index 498dac418..b29103e8b 100644 --- a/fish-rust/src/wcstringutil.rs +++ b/fish-rust/src/wcstringutil.rs @@ -249,7 +249,7 @@ pub fn rank(&self) -> u32 { pub fn string_fuzzy_match_string( string: &wstr, match_against: &wstr, - anchor_start: bool, + anchor_start: bool, /* = false */ ) -> Option { StringFuzzyMatch::try_create(string, match_against, anchor_start) } diff --git a/src/fish_tests.cpp b/src/fish_tests.cpp index e2fe11b7a..6a8e35d7d 100644 --- a/src/fish_tests.cpp +++ b/src/fish_tests.cpp @@ -1176,195 +1176,6 @@ static void test_abbreviations() { } } -// todo!("port this") -static void test_pager_navigation() { - say(L"Testing pager navigation"); - - // Generate 19 strings of width 10. There's 2 spaces between completions, and our term size is - // 80; these can therefore fit into 6 columns (6 * 12 - 2 = 70) or 5 columns (58) but not 7 - // columns (7 * 12 - 2 = 82). - // - // You can simulate this test by creating 19 files named "file00.txt" through "file_18.txt". - auto completions = new_completion_list(); - for (size_t i = 0; i < 19; i++) { - append_completion(*completions, L"abcdefghij"); - } - - pager_t pager; - pager.set_completions(*completions); - pager.set_term_size(termsize_default()); - page_rendering_t render = pager.render(); - - if (render.term_width != 80) err(L"Wrong term width"); - if (render.term_height != 24) err(L"Wrong term height"); - - size_t rows = 4, cols = 5; - - // We have 19 completions. We can fit into 6 columns with 4 rows or 5 columns with 4 rows; the - // second one is better and so is what we ought to have picked. - if (render.rows != rows) err(L"Wrong row count"); - if (render.cols != cols) err(L"Wrong column count"); - - // Initially expect to have no completion index. - if (render.selected_completion_idx != (size_t)(-1)) { - err(L"Wrong initial selection"); - } - - // Here are navigation directions and where we expect the selection to be. - const struct { - selection_motion_t dir; - size_t sel; - } cmds[] = { - // Tab completion to get into the list. - {selection_motion_t::next, 0}, - - // Westward motion in upper left goes to the last filled column in the last row. - {selection_motion_t::west, 15}, - // East goes back. - {selection_motion_t::east, 0}, - - {selection_motion_t::west, 15}, - {selection_motion_t::west, 11}, - {selection_motion_t::east, 15}, - {selection_motion_t::east, 0}, - - // "Next" motion goes down the column. - {selection_motion_t::next, 1}, - {selection_motion_t::next, 2}, - - {selection_motion_t::west, 17}, - {selection_motion_t::east, 2}, - {selection_motion_t::east, 6}, - {selection_motion_t::east, 10}, - {selection_motion_t::east, 14}, - {selection_motion_t::east, 18}, - - {selection_motion_t::west, 14}, - {selection_motion_t::east, 18}, - - // Eastward motion wraps back to the upper left, westward goes to the prior column. - {selection_motion_t::east, 3}, - {selection_motion_t::east, 7}, - {selection_motion_t::east, 11}, - {selection_motion_t::east, 15}, - - // Pages. - {selection_motion_t::page_north, 12}, - {selection_motion_t::page_south, 15}, - {selection_motion_t::page_north, 12}, - {selection_motion_t::east, 16}, - {selection_motion_t::page_south, 18}, - {selection_motion_t::east, 3}, - {selection_motion_t::north, 2}, - {selection_motion_t::page_north, 0}, - {selection_motion_t::page_south, 3}, - - }; - for (size_t i = 0; i < sizeof cmds / sizeof *cmds; i++) { - pager.select_next_completion_in_direction(cmds[i].dir, render); - pager.update_rendering(&render); - if (cmds[i].sel != render.selected_completion_idx) { - err(L"For command %lu, expected selection %lu, but found instead %lu\n", i, cmds[i].sel, - render.selected_completion_idx); - } - } -} - -struct pager_layout_testcase_t { - int width; - const wchar_t *expected; - - // Run ourselves as a test case. - // Set our data on the pager, and then check the rendering. - // We should have one line, and it should have our expected text. - void run(pager_t &pager) const { - pager.set_term_size(termsize_t{this->width, 24}); - page_rendering_t rendering = pager.render(); - const screen_data_t &sd = *rendering.screen_data; - do_test(sd.line_count() == 1); - if (sd.line_count() > 0) { - wcstring expected = this->expected; - - // hack: handle the case where ellipsis is not L'\x2026' - wchar_t ellipsis_char = get_ellipsis_char(); - if (ellipsis_char != L'\x2026') { - std::replace(expected.begin(), expected.end(), L'\x2026', ellipsis_char); - } - - wcstring text = *(sd.line_ffi(0)->text_characters_ffi()); - if (text != expected) { - std::fwprintf(stderr, L"width %d got %zu<%ls>, expected %zu<%ls>\n", this->width, - text.length(), text.c_str(), expected.length(), expected.c_str()); - for (size_t i = 0; i < std::max(text.length(), expected.length()); i++) { - std::fwprintf(stderr, L"i %zu got <%lx> expected <%lx>\n", i, - i >= text.length() ? 0xffff : text[i], - i >= expected.length() ? 0xffff : expected[i]); - } - } - do_test(text == expected); - } - } -}; - -// todo!("port this") -static void test_pager_layout() { - // These tests are woefully incomplete - // They only test the truncation logic for a single completion - say(L"Testing pager layout"); - pager_t pager; - - // These test cases have equal completions and descriptions - auto c1 = new_completion_with(L"abcdefghij", L"1234567890", 0); - auto c1s = new_completion_list(); - c1s->push_back(*c1); - pager.set_completions(*c1s); - const pager_layout_testcase_t testcases1[] = { - {26, L"abcdefghij (1234567890)"}, {25, L"abcdefghij (1234567890)"}, - {24, L"abcdefghij (1234567890)"}, {23, L"abcdefghij (12345678…)"}, - {22, L"abcdefghij (1234567…)"}, {21, L"abcdefghij (123456…)"}, - {20, L"abcdefghij (12345…)"}, {19, L"abcdefghij (1234…)"}, - {18, L"abcdefgh… (1234…)"}, {17, L"abcdefg… (1234…)"}, - {16, L"abcdefg… (123…)"}, {0, nullptr} // sentinel terminator - }; - for (size_t i = 0; testcases1[i].expected != nullptr; i++) { - testcases1[i].run(pager); - } - - // These test cases have heavyweight completions - auto c2 = new_completion_with(L"abcdefghijklmnopqrs", L"1", 0); - auto c2s = new_completion_list(); - c2s->push_back(*c2); - pager.set_completions(*c2s); - const pager_layout_testcase_t testcases2[] = { - {26, L"abcdefghijklmnopqrs (1)"}, {25, L"abcdefghijklmnopqrs (1)"}, - {24, L"abcdefghijklmnopqrs (1)"}, {23, L"abcdefghijklmnopq… (1)"}, - {22, L"abcdefghijklmnop… (1)"}, {21, L"abcdefghijklmno… (1)"}, - {20, L"abcdefghijklmn… (1)"}, {19, L"abcdefghijklm… (1)"}, - {18, L"abcdefghijkl… (1)"}, {17, L"abcdefghijk… (1)"}, - {16, L"abcdefghij… (1)"}, {0, nullptr} // sentinel terminator - }; - for (size_t i = 0; testcases2[i].expected != nullptr; i++) { - testcases2[i].run(pager); - } - - // These test cases have no descriptions - auto c3 = new_completion_with(L"abcdefghijklmnopqrst", L"", 0); - auto c3s = new_completion_list(); - c3s->push_back(*c3); - pager.set_completions(*c3s); - const pager_layout_testcase_t testcases3[] = { - {26, L"abcdefghijklmnopqrst"}, {25, L"abcdefghijklmnopqrst"}, - {24, L"abcdefghijklmnopqrst"}, {23, L"abcdefghijklmnopqrst"}, - {22, L"abcdefghijklmnopqrst"}, {21, L"abcdefghijklmnopqrst"}, - {20, L"abcdefghijklmnopqrst"}, {19, L"abcdefghijklmnopqr…"}, - {18, L"abcdefghijklmnopq…"}, {17, L"abcdefghijklmnop…"}, - {16, L"abcdefghijklmno…"}, {0, nullptr} // sentinel terminator - }; - for (size_t i = 0; testcases3[i].expected != nullptr; i++) { - testcases3[i].run(pager); - } -} - // todo!("port this") enum word_motion_t { word_motion_left, word_motion_right }; static void test_1_word_motion(word_motion_t motion, move_word_style_t style, @@ -2465,8 +2276,6 @@ static const test_t s_tests[]{ {TEST_GROUP("parser"), test_parser}, {TEST_GROUP("lru"), test_lru}, {TEST_GROUP("wcstod"), test_wcstod}, - {TEST_GROUP("pager_navigation"), test_pager_navigation}, - {TEST_GROUP("pager_layout"), test_pager_layout}, {TEST_GROUP("word_motion"), test_word_motion}, {TEST_GROUP("colors"), test_colors}, {TEST_GROUP("input"), test_input}, diff --git a/src/pager.cpp b/src/pager.cpp deleted file mode 100644 index bf10f1076..000000000 --- a/src/pager.cpp +++ /dev/null @@ -1,970 +0,0 @@ -#include "config.h" // IWYU pragma: keep - -#include "pager.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "common.h" -#include "complete.h" -#include "editable_line.rs.h" -#include "fallback.h" -#include "highlight.h" -#include "maybe.h" -#include "operation_context.h" -#include "reader.h" -#include "screen.h" -#include "termsize.h" -#include "wcstringutil.h" -#include "wutil.h" // IWYU pragma: keep - -using comp_t = pager_t::comp_t; -using comp_info_list_t = std::vector; - -comp_t &comp_t::operator=(const comp_t &other) { - if (this == &other) return *this; - comp = other.comp; - desc = other.desc; - representative = other.representative->clone(); - colors = other.colors; - comp_width = other.comp_width; - desc_width = other.desc_width; - return *this; -} - -comp_t::comp_t(const comp_t &other) { *this = other; } - -/// The minimum width (in characters) the terminal must to show completions at all. -#define PAGER_MIN_WIDTH 16 - -/// Minimum height to show completions -#define PAGER_MIN_HEIGHT 4 - -/// The maximum number of columns of completion to attempt to fit onto the screen. -#define PAGER_MAX_COLS 6 - -/// Width of the search field. -#define PAGER_SEARCH_FIELD_WIDTH 12 - -/// Text we use for the search field. -#define SEARCH_FIELD_PROMPT _(L"search: ") - -static inline bool selection_direction_is_cardinal(selection_motion_t dir) { - switch (dir) { - case selection_motion_t::north: - case selection_motion_t::east: - case selection_motion_t::south: - case selection_motion_t::west: - case selection_motion_t::page_north: - case selection_motion_t::page_south: { - return true; - } - case selection_motion_t::next: - case selection_motion_t::prev: - case selection_motion_t::deselect: { - return false; - } - } - - DIE("unreachable"); -} - -/// Returns numer / denom, rounding up. As a "courtesy" 0/0 is 0. -static size_t divide_round_up(size_t numer, size_t denom) { - if (numer == 0) return 0; - assert(denom > 0); - bool has_rem = (numer % denom) != 0; - return numer / denom + (has_rem ? 1 : 0); -} - -/// Print the specified string, but use at most the specified amount of space. If the whole string -/// can't be fitted, ellipsize it. -/// -/// \param str the string to print -/// \param color the color to apply to every printed character -/// \param max the maximum space that may be used for printing -/// \param has_more if this flag is true, this is not the entire string, and the string should be -/// ellipsized even if the string fits but takes up the whole space. -template -static typename std::enable_if< - std::is_convertible>::value, size_t>::type -print_max(const wcstring &str, Func color, size_t max, bool has_more, line_t *line) { - size_t remaining = max; - for (size_t i = 0; i < str.size(); i++) { - wchar_t c = str.at(i); - int iwidth_c = fish_wcwidth(c); - if (iwidth_c < 0) { - // skip non-printable characters - continue; - } - auto width_c = size_t(iwidth_c); - - if (width_c > remaining) break; - - wchar_t ellipsis = get_ellipsis_char(); - if ((width_c == remaining) && (has_more || i + 1 < str.size())) { - line->append(ellipsis, color(i)); - int ellipsis_width = fish_wcwidth(ellipsis); - remaining -= std::min(remaining, size_t(ellipsis_width)); - break; - } - - line->append(c, color(i)); - assert(remaining >= width_c); - remaining -= width_c; - } - - // return how much we consumed - assert(remaining <= max); - return max - remaining; -} - -static size_t print_max(const wcstring &str, highlight_spec_t color, size_t max, bool has_more, - line_t *line) { - return print_max( - str, [=](size_t) -> highlight_spec_t { return color; }, max, has_more, line); -} - -/// Print the specified item using at the specified amount of space. -rust::Box pager_t::completion_print_item(const wcstring &prefix, const comp_t *c, size_t row, - size_t column, size_t width, bool secondary, - bool selected, page_rendering_t *rendering) const { - UNUSED(column); - UNUSED(row); - UNUSED(rendering); - size_t comp_width; - rust::Box line_data_box = new_line(); - auto &line_data = *line_data_box; - - if (c->preferred_width() <= width) { - // The entry fits, we give it as much space as it wants. - comp_width = c->comp_width; - } else { - // The completion and description won't fit on the allocated space. Give a maximum of 2/3 of - // the space to the completion, and whatever is left to the description - // This expression is an overflow-safe way of calculating (width-4)*2/3 - size_t width_minus_spacer = width - std::min(width, size_t(4)); - size_t two_thirds_width = (width_minus_spacer / 3) * 2 + ((width_minus_spacer % 3) * 2) / 3; - comp_width = std::min(c->comp_width, two_thirds_width); - - // If the description is short, give the completion the remaining space - size_t desc_punct_width = c->description_punctuated_width(); - if (width > desc_punct_width) { - comp_width = std::max(comp_width, width - desc_punct_width); - } - - // The description gets what's left - assert(comp_width <= width); - } - - auto modify_role = [=](highlight_role_t role) -> highlight_role_t { - using uint_t = typename std::underlying_type::type; - auto base = static_cast(role); - if (selected) { - base += static_cast(highlight_role_t::pager_selected_background) - - static_cast(highlight_role_t::pager_background); - } else if (secondary) { - base += static_cast(highlight_role_t::pager_secondary_background) - - static_cast(highlight_role_t::pager_background); - } - return static_cast(base); - }; - - highlight_role_t bg_role = modify_role(highlight_role_t::pager_background); - highlight_spec_t bg = {highlight_role_t::normal, bg_role}; - highlight_spec_t prefix_col = { - modify_role(highlight_prefix ? highlight_role_t::pager_prefix - : highlight_role_t::pager_completion), - bg_role}; - highlight_spec_t comp_col = {modify_role(highlight_role_t::pager_completion), bg_role}; - highlight_spec_t desc_col = {modify_role(highlight_role_t::pager_description), bg_role}; - - // Print the completion part - size_t comp_remaining = comp_width; - for (size_t i = 0; i < c->comp.size(); i++) { - const wcstring &comp = c->comp.at(i); - - if (i > 0) { - comp_remaining -= - print_max(PAGER_SPACER_STRING, bg, comp_remaining, true /* has_more */, &line_data); - } - - comp_remaining -= print_max(prefix, prefix_col, comp_remaining, !comp.empty(), &line_data); - comp_remaining -= print_max( - comp, - [&](size_t i) -> highlight_spec_t { - if (c->colors.empty()) return comp_col; // Not a shell command. - if (selected) return comp_col; // Rendered in reverse video, so avoid highlighting. - return i < c->colors.size() ? c->colors[i] : c->colors.back(); - }, - comp_remaining, i + 1 < c->comp.size(), &line_data); - } - - size_t desc_remaining = width - comp_width + comp_remaining; - if (c->desc_width > 0 && desc_remaining > 4) { - // always have at least two spaces to separate completion and description - desc_remaining -= print_max(L" ", bg, 2, false, &line_data); - - // right-justify the description by adding spaces - // the 2 here refers to the parenthesis below - while (desc_remaining > c->desc_width + 2) { - desc_remaining -= print_max(L" ", bg, 1, false, &line_data); - } - - assert(desc_remaining >= 2); - highlight_spec_t paren_col = {highlight_role_t::pager_completion, bg_role}; - desc_remaining -= print_max(L"(", paren_col, 1, false, &line_data); - desc_remaining -= print_max(c->desc, desc_col, desc_remaining - 1, false, &line_data); - desc_remaining -= print_max(L")", paren_col, 1, false, &line_data); - } else { - // No description, or it won't fit. Just add spaces. - print_max(wcstring(desc_remaining, L' '), bg, desc_remaining, false, &line_data); - } - - return line_data_box; -} - -/// Print the specified part of the completion list, using the specified column offsets and quoting -/// style. -/// -/// \param cols number of columns to print in -/// \param width_by_column An array specifying the width of each column -/// \param row_start The first row to print -/// \param row_stop the row after the last row to print -/// \param prefix The string to print before each completion -/// \param lst The list of completions to print -void pager_t::completion_print(size_t cols, const size_t *width_by_column, size_t row_start, - size_t row_stop, const wcstring &prefix, const comp_info_list_t &lst, - page_rendering_t *rendering) const { - // Teach the rendering about the rows it printed. - assert(row_stop >= row_start); - rendering->row_start = row_start; - rendering->row_end = row_stop; - - size_t rows = divide_round_up(lst.size(), cols); - - size_t effective_selected_idx = this->visual_selected_completion_index(rows, cols); - - for (size_t row = row_start; row < row_stop; row++) { - for (size_t col = 0; col < cols; col++) { - if (lst.size() <= col * rows + row) continue; - - size_t idx = col * rows + row; - const comp_t *el = &lst.at(idx); - bool is_selected = (idx == effective_selected_idx); - - // Print this completion on its own "line". - auto line = completion_print_item(prefix, el, row, col, width_by_column[col], row % 2, - is_selected, rendering); - - // If there's more to come, append two spaces. - if (col + 1 < cols) { - line->append_str(PAGER_SPACER_STRING, highlight_spec_t{}); - } - - // Append this to the real line. - rendering->screen_data->create_line(row - row_start)->append_line(*line); - } - } -} - -/// Trim leading and trailing whitespace, and compress other whitespace runs into a single space. -static void mangle_1_completion_description(wcstring *str) { - size_t leading = 0, trailing = 0, len = str->size(); - - // Skip leading spaces. - for (; leading < len; leading++) { - if (!iswspace(str->at(leading))) break; - } - - // Compress runs of spaces to a single space. - bool was_space = false; - for (; leading < len; leading++) { - wchar_t wc = str->at(leading); - bool is_space = iswspace(wc); - if (!is_space) { // normal character - str->at(trailing++) = wc; - } else if (!was_space) { // initial space in a run - str->at(trailing++) = L' '; - } // else non-initial space in a run, do nothing - was_space = is_space; - } - - // leading is now at len, trailing is the new length of the string. Delete trailing spaces. - while (trailing > 0 && iswspace(str->at(trailing - 1))) { - trailing--; - } - - str->resize(trailing); -} - -static void join_completions(comp_info_list_t *comps) { - // A map from description to index in the completion list of the element with that description. - // The indexes are stored +1. - std::unordered_map desc_table; - - // Note that we mutate the completion list as we go, so the size changes. - for (size_t i = 0; i < comps->size(); i++) { - const comp_t &new_comp = comps->at(i); - const wcstring &desc = new_comp.desc; - if (desc.empty()) continue; - - // See if it's in the table. - size_t prev_idx_plus_one = desc_table[desc]; - if (prev_idx_plus_one == 0) { - // We're the first with this description. - desc_table[desc] = i + 1; - } else { - // There's a prior completion with this description. Append the new ones to it. - comp_t *prior_comp = &comps->at(prev_idx_plus_one - 1); - prior_comp->comp.insert(prior_comp->comp.end(), new_comp.comp.begin(), - new_comp.comp.end()); - - // Erase the element at this index, and decrement the index to reflect that fact. - comps->erase(comps->begin() + i); - i -= 1; - } - } -} - -/// Generate a list of comp_t structures from a list of completions. -static comp_info_list_t process_completions_into_infos(const completion_list_t &lst) { - const size_t lst_size = lst.size(); - - // Make the list of the correct size up-front. - comp_info_list_t result(lst_size); - for (size_t i = 0; i < lst_size; i++) { - const completion_t &comp = lst.at(i); - comp_t *comp_info = &result.at(i); - - // Append the single completion string. We may later merge these into multiple. - comp_info->comp.push_back(escape_string( - *comp.completion(), ESCAPE_NO_PRINTABLES | ESCAPE_NO_QUOTED | ESCAPE_SYMBOLIC)); - if (comp.replaces_commandline() - // HACK We want to render a full shell command, with syntax highlighting. Above we - // escape nonprintables, which might make the rendered command longer than the original - // completion. In that case we get wrong colors. However this should only happen in - // contrived cases, since our symbolic escaping uses a single character to represent - // newline and tab characters; other nonprintables are extremely rare in a command - // line. It will only be common for single-byte locales where we don't - // use Unicode characters for escaping, so just disable those here. - // We should probably fix this by first highlighting the original completion, and - // then writing a variant of escape_string() that adjusts highlighting according - // so it matches the escaped string. - && MB_CUR_MAX > 1) { - highlight_shell(*comp.completion(), comp_info->colors, *empty_operation_context()); - assert(comp_info->comp.back().size() >= comp_info->colors.size()); - } - - // Append the mangled description. - comp_info->desc = std::move(*comp.description()); - mangle_1_completion_description(&comp_info->desc); - - // Set the representative completion. - comp_info->representative = comp.clone(); - } - return result; -} - -void pager_t::measure_completion_infos(comp_info_list_t *infos, const wcstring &prefix) const { - size_t prefix_len = fish_wcswidth(prefix); - for (auto &info : *infos) { - comp_t *comp = &info; - const std::vector &comp_strings = comp->comp; - - for (size_t j = 0; j < comp_strings.size(); j++) { - // If there's more than one, append the length of ', '. - if (j >= 1) comp->comp_width += 2; - - // fish_wcswidth() can return -1 if it can't calculate the width. So be cautious. - int comp_width = fish_wcswidth(comp_strings.at(j)); - if (comp_width >= 0) comp->comp_width += prefix_len + comp_width; - } - - // fish_wcswidth() can return -1 if it can't calculate the width. So be cautious. - int desc_width = fish_wcswidth(comp->desc); - comp->desc_width = desc_width > 0 ? desc_width : 0; - } -} - -// Indicates if the given completion info passes any filtering we have. -bool pager_t::completion_info_passes_filter(const comp_t &info) const { - // If we have no filter, everything passes. - if (!search_field_shown || this->search_field_line.empty()) return true; - - const wcstring needle = *this->search_field_line.text(); - - // Match against the description. - if (string_fuzzy_match_string(needle, info.desc)) { - return true; - } - - // Match against the completion strings. - for (const auto &i : info.comp) { - if (string_fuzzy_match_string(needle, prefix + i)) { - return true; - } - } - - return false; // no match -} - -// Update completion_infos from unfiltered_completion_infos, to reflect the filter. -void pager_t::refilter_completions() { - this->completion_infos.clear(); - for (const auto &info : this->unfiltered_completion_infos) { - if (this->completion_info_passes_filter(info)) { - this->completion_infos.push_back(info); - } - } -} - -void pager_t::set_completions(const completion_list_t &raw_completions) { - selected_completion_idx = PAGER_SELECTION_NONE; - // Get completion infos out of it. - unfiltered_completion_infos = process_completions_into_infos(raw_completions); - - // Maybe join them. - if (prefix == L"-") join_completions(&unfiltered_completion_infos); - - // Compute their various widths. - measure_completion_infos(&unfiltered_completion_infos, prefix); - - // Refilter them. - this->refilter_completions(); - have_unrendered_completions = true; -} - -void pager_t::set_prefix(const wcstring &pref, bool highlight) { - prefix = pref; - highlight_prefix = highlight; -} - -void pager_t::set_term_size(const termsize_t &ts) { - available_term_width = ts.width > 0 ? ts.width : 0; - available_term_height = ts.height > 0 ? ts.height : 0; -} - -void pager_set_term_size_ffi(pager_t &pager, const void *ts) { - pager.set_term_size(*reinterpret_cast(ts)); -} - -void pager_update_rendering_ffi(pager_t &pager, page_rendering_t &rendering) { - pager.update_rendering(&rendering); -} - -/// Try to print the list of completions lst with the prefix prefix using cols as the number of -/// columns. Return true if the completion list was printed, false if the terminal is too narrow for -/// the specified number of columns. Always succeeds if cols is 1. -bool pager_t::completion_try_print(size_t cols, const wcstring &prefix, const comp_info_list_t &lst, - page_rendering_t *rendering, size_t suggested_start_row) const { - assert(cols > 0); - // The calculated preferred width of each column. - size_t width_by_column[PAGER_MAX_COLS] = {0}; - - // Skip completions on tiny terminals. - if (this->available_term_width < PAGER_MIN_WIDTH || - this->available_term_height < PAGER_MIN_HEIGHT) - return true; - - // Compute the effective term width and term height, accounting for disclosure. - size_t term_width = this->available_term_width; - size_t term_height = - this->available_term_height - 1 - - (search_field_shown ? 1 : 0); // we always subtract 1 to make room for a comment row - if (!this->fully_disclosed) { - // We disclose between half and the entirety of the terminal height, - // but at least 4 rows. - // - // We do this so we show a useful amount but don't force fish to - // THE VERY TOP, which is jarring. - term_height = - std::min(term_height, - std::max(term_height / 2, static_cast(PAGER_UNDISCLOSED_MAX_ROWS))); - } - - size_t row_count = divide_round_up(lst.size(), cols); - - // We have more to disclose if we are not fully disclosed and there's more rows than we have in - // our term height. - if (!this->fully_disclosed && row_count > term_height) { - rendering->remaining_to_disclose = row_count - term_height; - } else { - rendering->remaining_to_disclose = 0; - } - - // If we have only one row remaining to disclose, then squelch the comment row. This prevents us - // from consuming a line to show "...and 1 more row". - if (rendering->remaining_to_disclose == 1) { - term_height += 1; - rendering->remaining_to_disclose = 0; - } - - // Calculate how wide the list would be. - for (size_t col = 0; col < cols; col++) { - for (size_t row = 0; row < row_count; row++) { - const size_t comp_idx = col * row_count + row; - if (comp_idx >= lst.size()) continue; - const comp_t &c = lst.at(comp_idx); - width_by_column[col] = std::max(width_by_column[col], c.preferred_width()); - } - } - - bool print; - // Force fit if one column. - if (cols == 1) { - width_by_column[0] = std::min(width_by_column[0], term_width); - print = true; - } else { - // Compute total preferred width, plus spacing - size_t total_width_needed = std::accumulate(width_by_column, width_by_column + cols, 0); - total_width_needed += (cols - 1) * PAGER_SPACER_STRING_WIDTH; - print = (total_width_needed <= term_width); - } - if (!print) { - return false; // no need to continue - } - - // Determine the starting and stop row. - size_t start_row = 0, stop_row = 0; - if (row_count <= term_height) { - // Easy, we can show everything. - start_row = 0; - stop_row = row_count; - } else { - // We can only show part of the full list. Determine which part based on the - // suggested_start_row. - assert(row_count > term_height); - size_t last_starting_row = row_count - term_height; - start_row = std::min(suggested_start_row, last_starting_row); - stop_row = start_row + term_height; - assert(start_row <= last_starting_row); - } - - assert(stop_row >= start_row); - assert(stop_row <= row_count); - assert(stop_row - start_row <= term_height); - completion_print(cols, width_by_column, start_row, stop_row, prefix, lst, rendering); - - // Add the progress line. It's a "more to disclose" line if necessary, or a row listing if - // it's scrollable; otherwise ignore it. - // We should never have one row remaining to disclose (else we would have just disclosed it) - wcstring progress_text; - assert(rendering->remaining_to_disclose != 1); - if (rendering->remaining_to_disclose > 1) { - progress_text = format_string(_(L"%lsand %lu more rows"), get_ellipsis_str(), - static_cast(rendering->remaining_to_disclose)); - } else if (start_row > 0 || stop_row < row_count) { - // We have a scrollable interface. The +1 here is because we are zero indexed, but want - // to present things as 1-indexed. We do not add 1 to stop_row or row_count because - // these are the "past the last value". - progress_text = - format_string(_(L"rows %lu to %lu of %lu"), start_row + 1, stop_row, row_count); - } else if (search_field_shown && completion_infos.empty()) { - // Everything is filtered. - progress_text = _(L"(no matches)"); - } - if (!extra_progress_text.empty()) { - if (!progress_text.empty()) { - progress_text += L". "; - } - progress_text += extra_progress_text; - } - - if (!progress_text.empty()) { - line_t &line = rendering->screen_data->add_line(); - highlight_spec_t spec = {highlight_role_t::pager_progress, - highlight_role_t::pager_progress}; - print_max(progress_text, spec, term_width, true /* has_more */, &line); - } - - if (!search_field_shown) { - return true; - } - - // Add the search field. - wcstring search_field_text = *search_field_line.text(); - // Append spaces to make it at least the required width. - if (search_field_text.size() < PAGER_SEARCH_FIELD_WIDTH) { - search_field_text.append(PAGER_SEARCH_FIELD_WIDTH - search_field_text.size(), L' '); - } - line_t *search_field = &rendering->screen_data->insert_line_at_index(0); - - // We limit the width to term_width - 1. - highlight_spec_t underline{}; - underline->force_underline = true; - - size_t search_field_remaining = term_width - 1; - search_field_remaining -= print_max(SEARCH_FIELD_PROMPT, highlight_role_t::normal, - search_field_remaining, false, search_field); - search_field_remaining -= - print_max(search_field_text, underline, search_field_remaining, false, search_field); - return true; -} - -page_rendering_t pager_t::render() const { - /// Try to print the completions. Start by trying to print the list in PAGER_MAX_COLS columns, - /// if the completions won't fit, reduce the number of columns by one. Printing a single column - /// never fails. - page_rendering_t rendering; - rendering.term_width = this->available_term_width; - rendering.term_height = this->available_term_height; - rendering.search_field_shown = this->search_field_shown; - rendering.search_field_line = this->search_field_line.clone(); - - for (size_t cols = PAGER_MAX_COLS; cols > 0; cols--) { - // Initially empty rendering. - rendering.screen_data->resize(0); - - // Determine how many rows we would need if we had 'cols' columns. Then determine how many - // columns we want from that. For example, say we had 19 completions. We can fit them into 6 - // columns, 4 rows, with the last row containing only 1 entry. Or we can fit them into 5 - // columns, 4 rows, the last row containing 4 entries. Since fewer columns with the same - // number of rows is better, skip cases where we know we can do better. - size_t min_rows_required_for_cols = divide_round_up(completion_infos.size(), cols); - size_t min_cols_required_for_rows = - divide_round_up(completion_infos.size(), min_rows_required_for_cols); - - assert(min_cols_required_for_rows <= cols); - if (cols > 1 && min_cols_required_for_rows < cols) { - // Next iteration will be better, so skip this one. - continue; - } - - rendering.cols = cols; - rendering.rows = min_rows_required_for_cols; - rendering.selected_completion_idx = - this->visual_selected_completion_index(rendering.rows, rendering.cols); - - if (completion_try_print(cols, prefix, completion_infos, &rendering, suggested_row_start)) { - break; - } - } - return rendering; -} - -bool pager_t::rendering_needs_update(const page_rendering_t &rendering) const { - if (have_unrendered_completions) return true; - // Common case is no pager. - if (this->empty() && rendering.screen_data->empty()) return false; - - return (this->empty() && !rendering.screen_data->empty()) || // Do update after clear(). - rendering.term_width != this->available_term_width || // - rendering.term_height != this->available_term_height || // - rendering.selected_completion_idx != - this->visual_selected_completion_index(rendering.rows, rendering.cols) || // - rendering.search_field_shown != this->search_field_shown || // - *rendering.search_field_line->text() != *this->search_field_line.text() || // - rendering.search_field_line->position() != this->search_field_line.position() || // - (rendering.remaining_to_disclose > 0 && this->fully_disclosed); -} - -void pager_t::update_rendering(page_rendering_t *rendering) { - if (rendering_needs_update(*rendering)) { - *rendering = this->render(); - have_unrendered_completions = false; - } -} - -pager_t::pager_t() : search_field_line_box(new_editable_line()) {} -pager_t::~pager_t() = default; - -bool pager_t::empty() const { return unfiltered_completion_infos.empty(); } - -bool pager_t::select_next_completion_in_direction(selection_motion_t direction, - const page_rendering_t &rendering) { - // Must have something to select. - if (this->completion_infos.empty()) { - return false; - } - - if (selected_completion_idx == PAGER_SELECTION_NONE) { - // Handle the case of nothing selected yet. - switch (direction) { - case selection_motion_t::south: - case selection_motion_t::page_south: - case selection_motion_t::next: - case selection_motion_t::north: - case selection_motion_t::prev: { - // These directions do something sane. - if (direction == selection_motion_t::prev || - direction == selection_motion_t::north) { - selected_completion_idx = completion_infos.size() - 1; - } else { - selected_completion_idx = 0; - } - break; - } - case selection_motion_t::page_north: - case selection_motion_t::east: - case selection_motion_t::west: - case selection_motion_t::deselect: { - // These do nothing. - return false; - } - } - } else { - // Ok, we had something selected already. Select something different. - size_t new_selected_completion_idx; - if (!selection_direction_is_cardinal(direction)) { - // Next, previous, or deselect, all easy. - if (direction == selection_motion_t::deselect) { - new_selected_completion_idx = PAGER_SELECTION_NONE; - } else if (direction == selection_motion_t::next) { - new_selected_completion_idx = selected_completion_idx + 1; - if (new_selected_completion_idx >= completion_infos.size()) { - new_selected_completion_idx = 0; - } - } else if (direction == selection_motion_t::prev) { - if (selected_completion_idx == 0) { - new_selected_completion_idx = completion_infos.size() - 1; - } else { - new_selected_completion_idx = selected_completion_idx - 1; - } - } else { - DIE("unknown non-cardinal direction"); - } - } else { - // Cardinal directions. We have a completion index; we wish to compute its row and - // column. - size_t current_row = this->get_selected_row(rendering); - size_t current_col = this->get_selected_column(rendering); - size_t page_height = std::max(rendering.term_height - 1, static_cast(1)); - - switch (direction) { - case selection_motion_t::page_north: { - if (current_row > page_height) { - current_row = current_row - page_height; - } else { - current_row = 0; - } - break; - } - case selection_motion_t::north: { - // Go up a whole row. If we cycle, go to the previous column. - if (current_row > 0) { - current_row--; - } else { - current_row = rendering.rows - 1; - if (current_col > 0) { - current_col--; - } else { - current_col = rendering.cols - 1; - } - } - break; - } - case selection_motion_t::page_south: { - if (current_row + page_height < rendering.rows) { - current_row += page_height; - } else { - current_row = rendering.rows - 1; - if (current_col * rendering.rows + current_row >= completion_infos.size()) { - current_row = (completion_infos.size() - 1) % rendering.rows; - } - } - break; - } - case selection_motion_t::south: { - // Go down, unless we are in the last row. - // If we go over the last element, wrap to the first. - if (current_row + 1 < rendering.rows && - current_col * rendering.rows + current_row + 1 < completion_infos.size()) { - current_row++; - } else { - current_row = 0; - current_col = (current_col + 1) % rendering.cols; - } - break; - } - case selection_motion_t::east: { - // Go east, wrapping to the next row. There is no "row memory," so if we run off - // the end, wrap. - if (current_col + 1 < rendering.cols && - (current_col + 1) * rendering.rows + current_row < - completion_infos.size()) { - current_col++; - } else { - current_col = 0; - current_row = (current_row + 1) % rendering.rows; - } - break; - } - case selection_motion_t::west: { - // Go west, wrapping to the previous row. - if (current_col > 0) { - current_col--; - } else { - current_col = rendering.cols - 1; - if (current_row > 0) { - current_row--; - } else { - current_row = rendering.rows - 1; - } - } - break; - } - default: { - DIE("unknown cardinal direction"); - } - } - - // Compute the new index based on the changed row. - new_selected_completion_idx = current_col * rendering.rows + current_row; - } - - if (selected_completion_idx == new_selected_completion_idx) { - return false; - } - selected_completion_idx = new_selected_completion_idx; - } - - // Update suggested_row_start to ensure the selection is visible. suggested_row_start * - // rendering.cols is the first suggested visible completion; add the visible completion - // count to that to get the last one. - size_t visible_row_count = rendering.row_end - rendering.row_start; - if (visible_row_count == 0) { - return true; // this happens if there was no room to draw the pager - } - if (selected_completion_idx == PAGER_SELECTION_NONE) { - return true; // this should never happen but be paranoid - } - - // Ensure our suggested row start is not past the selected row. - size_t row_containing_selection = this->get_selected_row(rendering.rows); - if (suggested_row_start > row_containing_selection) { - suggested_row_start = row_containing_selection; - } - - // Ensure our suggested row start is not too early before it. - if (suggested_row_start + visible_row_count <= row_containing_selection) { - // The user moved south past the bottom completion. - if (!fully_disclosed && rendering.remaining_to_disclose > 0) { - fully_disclosed = true; // perform disclosure - } else { - // Scroll - suggested_row_start = row_containing_selection - visible_row_count + 1; - // Ensure fully_disclosed is set. I think we can hit this case if the user - // resizes the window - we don't want to drop back to the disclosed style. - fully_disclosed = true; - } - } - - return true; -} - -size_t pager_t::visual_selected_completion_index(size_t rows, size_t cols) const { - // No completions -> no selection. - if (completion_infos.empty()) { - return PAGER_SELECTION_NONE; - } - - size_t result = selected_completion_idx; - if (result == 0) { - return result; - } - if (rows == 0 || cols == 0) { - return PAGER_SELECTION_NONE; - } - if (result != PAGER_SELECTION_NONE) { - // If the selected completion is beyond the last selection, go left by columns until it's - // within it. This is how we implement "column memory". - while (result >= completion_infos.size() && result >= rows) { - result -= rows; - } - - // If we are still beyond the last selection, clamp it. - if (result >= completion_infos.size()) result = completion_infos.size() - 1; - } - assert(result == PAGER_SELECTION_NONE || result < completion_infos.size()); - return result; -} - -// It's possible we have no visual selection but are still navigating the contents, e.g. every -// completion is filtered. -bool pager_t::is_navigating_contents() const { - return selected_completion_idx != PAGER_SELECTION_NONE; -} - -void pager_t::set_fully_disclosed() { fully_disclosed = true; } - -const completion_t *pager_t::selected_completion(const page_rendering_t &rendering) const { - const completion_t *result = nullptr; - size_t idx = visual_selected_completion_index(rendering.rows, rendering.cols); - if (idx != PAGER_SELECTION_NONE) { - result = &*completion_infos.at(idx).representative; - } - return result; -} - -size_t pager_t::selected_completion_index() const { return selected_completion_idx; } - -void pager_t::set_selected_completion_index(size_t new_index) { - // Current users are off by one at most. - assert(new_index == PAGER_SELECTION_NONE || new_index <= completion_infos.size()); - if (new_index == completion_infos.size()) --new_index; - selected_completion_idx = new_index; -} - -/// Get the selected row and column. Completions are rendered column first, i.e. we go south before -/// we go west. So if we have N rows, and our selected index is N + 2, then our row is 2 (mod by N) -/// and our column is 1 (divide by N). -size_t pager_t::get_selected_row(const page_rendering_t &rendering) const { - if (rendering.rows == 0) return PAGER_SELECTION_NONE; - - return rendering.selected_completion_idx == PAGER_SELECTION_NONE - ? PAGER_SELECTION_NONE - : rendering.selected_completion_idx % rendering.rows; -} - -size_t pager_t::get_selected_row(size_t rows) const { - if (rows == 0) return PAGER_SELECTION_NONE; - - return selected_completion_idx == PAGER_SELECTION_NONE ? PAGER_SELECTION_NONE - : selected_completion_idx % rows; -} - -size_t pager_t::get_selected_column(const page_rendering_t &rendering) const { - if (rendering.rows == 0) return PAGER_SELECTION_NONE; - - return rendering.selected_completion_idx == PAGER_SELECTION_NONE - ? PAGER_SELECTION_NONE - : rendering.selected_completion_idx / rendering.rows; -} - -void pager_t::clear() { - unfiltered_completion_infos.clear(); - completion_infos.clear(); - prefix.clear(); - highlight_prefix = false; - selected_completion_idx = PAGER_SELECTION_NONE; - fully_disclosed = false; - search_field_shown = false; - search_field_line.clear(); - extra_progress_text.clear(); -} - -void pager_t::set_search_field_shown(bool flag) { this->search_field_shown = flag; } - -bool pager_t::is_search_field_shown() const { return this->search_field_shown; } - -size_t pager_t::cursor_position() const { - size_t result = std::wcslen(SEARCH_FIELD_PROMPT) + this->search_field_line.position(); - // Clamp it to the right edge. - if (available_term_width > 0 && result + 1 > available_term_width) { - result = available_term_width - 1; - } - return result; -} - -page_rendering_t::page_rendering_t() - : screen_data(new_screen_data()), search_field_line(new_editable_line()) {} diff --git a/src/pager.h b/src/pager.h index 9087cec7e..300e04288 100644 --- a/src/pager.h +++ b/src/pager.h @@ -18,219 +18,15 @@ struct termsize_t; #define PAGER_SELECTION_NONE static_cast(-1) -/// Represents rendering from the pager. -class page_rendering_t { - public: - size_t term_width{size_t(-1)}; - size_t term_height{size_t(-1)}; - size_t rows{0}; - size_t cols{0}; - size_t row_start{0}; - size_t row_end{0}; - size_t selected_completion_idx{size_t(-1)}; - rust::Box screen_data; - - const screen_data_t *screen_data_ffi() const { return &*screen_data; } - size_t remaining_to_disclose{0}; - - bool search_field_shown{false}; #if INCLUDE_RUST_HEADERS - rust::Box search_field_line; +#include "pager.rs.h" +#else +struct PageRendering; +enum class selection_motion_t; +struct Pager; #endif - // Returns a rendering with invalid data, useful to indicate "no rendering". - page_rendering_t(); - page_rendering_t(const page_rendering_t &) = delete; - page_rendering_t(page_rendering_t &&) = default; - page_rendering_t &operator=(const page_rendering_t &) = delete; - page_rendering_t &operator=(page_rendering_t &&) = default; -}; - -enum class selection_motion_t { - // Visual directions. - north, - east, - south, - west, - page_north, - page_south, - - // Logical directions. - next, - prev, - - // Special value that means deselect. - deselect -}; - -// The space between adjacent completions. -#define PAGER_SPACER_STRING L" " -#define PAGER_SPACER_STRING_WIDTH 2 - -// How many rows we will show in the "initial" pager. -#define PAGER_UNDISCLOSED_MAX_ROWS 4 - -class pager_t { - size_t available_term_width{0}; - size_t available_term_height{0}; - - size_t selected_completion_idx{PAGER_SELECTION_NONE}; - size_t suggested_row_start{0}; - - // Fully disclosed means that we show all completions. - bool fully_disclosed{false}; - - // Whether we show the search field. - bool search_field_shown{false}; - - // Returns the index of the completion that should draw selected, using the given number of - // columns. - size_t visual_selected_completion_index(size_t rows, size_t cols) const; - - public: - /// Data structure describing one or a group of related completions. - struct comp_t { - /// The list of all completion strings this entry applies to. - std::vector comp{}; - /// The description. - wcstring desc{}; -#if INCLUDE_RUST_HEADERS - /// The representative completion. - rust::Box representative = new_completion(); -#endif - /// The per-character highlighting, used when this is a full shell command. - std::vector colors{}; - /// On-screen width of the completion string. - size_t comp_width{0}; - /// On-screen width of the description information. - size_t desc_width{0}; - - comp_t() = default; - comp_t(const comp_t &other); - comp_t &operator=(const comp_t &other); - comp_t(comp_t &&) = default; - comp_t &operator=(comp_t &&) = default; - - // Our text looks like this: - // completion (description) - // Two spaces separating, plus parens, yields 4 total extra space - // but only if we have a description of course - size_t description_punctuated_width() const { - return this->desc_width + (this->desc_width ? 4 : 0); - } - - // Returns the preferred width, containing the sum of the - // width of the completion, separator, description - size_t preferred_width() const { - return this->comp_width + this->description_punctuated_width(); - } - }; - - private: - using comp_info_list_t = std::vector; - - // The filtered list of completion infos. - comp_info_list_t completion_infos; - - // The unfiltered list. Note there's a lot of duplication here. - comp_info_list_t unfiltered_completion_infos; - - // This tracks if the completion list has been changed since we last rendered. If yes, - // then we definitely need to re-render. - bool have_unrendered_completions = false; - - wcstring prefix; - bool highlight_prefix = false; - - bool completion_try_print(size_t cols, const wcstring &prefix, const comp_info_list_t &lst, - page_rendering_t *rendering, size_t suggested_start_row) const; - - void recalc_min_widths(comp_info_list_t *lst) const; - void measure_completion_infos(std::vector *infos, const wcstring &prefix) const; - - bool completion_info_passes_filter(const comp_t &info) const; - - void completion_print(size_t cols, const size_t *width_by_column, size_t row_start, - size_t row_stop, const wcstring &prefix, const comp_info_list_t &lst, - page_rendering_t *rendering) const; - rust::Box completion_print_item(const wcstring &prefix, const comp_t *c, size_t row, - size_t column, size_t width, bool secondary, - bool selected, page_rendering_t *rendering) const; - - public: -// The text of the search field. -#if INCLUDE_RUST_HEADERS - rust::Box search_field_line_box; - editable_line_t &search_field_line = *search_field_line_box; -#endif - - // Extra text to display at the bottom of the pager. - wcstring extra_progress_text{}; - - // Sets the set of completions. - void set_completions(const completion_list_t &raw_completions); - - // Sets the prefix. - void set_prefix(const wcstring &pref, bool highlight = true); - - // Sets the terminal size. - void set_term_size(const termsize_t &ts); - - // Changes the selected completion in the given direction according to the layout of the given - // rendering. Returns true if the selection changed. - bool select_next_completion_in_direction(selection_motion_t direction, - const page_rendering_t &rendering); - - // Returns the currently selected completion for the given rendering. - const completion_t *selected_completion(const page_rendering_t &rendering) const; - - size_t selected_completion_index() const; - void set_selected_completion_index(size_t new_index); - - // Indicates the row and column for the given rendering. Returns -1 if no selection. - size_t get_selected_row(const page_rendering_t &rendering) const; - size_t get_selected_column(const page_rendering_t &rendering) const; - // Indicates the row assuming we render this many rows. Returns -1 if no selection. - size_t get_selected_row(size_t rows) const; - - // Produces a rendering of the completions, at the given term size. - page_rendering_t render() const; - - // \return true if the given rendering needs to be updated. - bool rendering_needs_update(const page_rendering_t &rendering) const; - - // Updates the rendering. - void update_rendering(page_rendering_t *rendering); - - // Indicates if there are no completions, and therefore nothing to render. - bool empty() const; - - // Clears all completions and the prefix. - void clear(); - - // Updates the completions list per the filter. - void refilter_completions(); - - // Sets whether the search field is shown. - void set_search_field_shown(bool flag); - - // Gets whether the search field shown. - bool is_search_field_shown() const; - - // Indicates if we are navigating our contents. - bool is_navigating_contents() const; - - // Become fully disclosed. - void set_fully_disclosed(); - - // Position of the cursor. - size_t cursor_position() const; - - pager_t(); - ~pager_t(); -}; - -void pager_set_term_size_ffi(pager_t &pager, const void *ts); -void pager_update_rendering_ffi(pager_t &pager, page_rendering_t &rendering); +using page_rendering_t = PageRendering; +using pager_t = Pager; #endif diff --git a/src/reader.cpp b/src/reader.cpp index fdd07cb63..4a2d7704f 100644 --- a/src/reader.cpp +++ b/src/reader.cpp @@ -548,9 +548,10 @@ class reader_data_t : public std::enable_shared_from_this { /// The current autosuggestion. autosuggestion_t autosuggestion; /// Current pager. - pager_t pager; + rust::Box pager_box = new_pager(); + pager_t &pager = *pager_box; /// The output of the pager. - page_rendering_t current_page_rendering; + rust::Box current_page_rendering = new_page_rendering(); /// When backspacing, we temporarily suppress autosuggestions. bool suppress_autosuggestion{false}; @@ -626,7 +627,7 @@ class reader_data_t : public std::enable_shared_from_this { /// field. const editable_line_t *active_edit_line() const { if (this->is_navigating_pager_contents() && this->pager.is_search_field_shown()) { - return &this->pager.search_field_line; + return this->pager.search_field_line(); } return &this->command_line; } @@ -985,7 +986,7 @@ bool reader_data_t::is_repaint_needed(const std::vector *mcolo return val; }; - bool focused_on_pager = active_edit_line() == &pager.search_field_line; + bool focused_on_pager = active_edit_line() == pager.search_field_line(); const layout_data_t &last = this->rendered_layout; return check(force_exec_prompt_and_repaint, L"forced") || check(*command_line.text() != last.text, L"text") || @@ -999,12 +1000,12 @@ bool reader_data_t::is_repaint_needed(const std::vector *mcolo check(left_prompt_buff != last.left_prompt_buff, L"left_prompt") || check(mode_prompt_buff != last.mode_prompt_buff, L"mode_prompt") || check(right_prompt_buff != last.right_prompt_buff, L"right_prompt") || - check(pager.rendering_needs_update(current_page_rendering), L"pager"); + check(pager.rendering_needs_update(*current_page_rendering), L"pager"); } layout_data_t reader_data_t::make_layout_data() const { layout_data_t result{}; - bool focused_on_pager = active_edit_line() == &pager.search_field_line; + bool focused_on_pager = active_edit_line() == pager.search_field_line(); result.text = *command_line.text(); for (auto &color : editable_line_colors(command_line)) { result.colors.push_back(color); @@ -1012,7 +1013,7 @@ layout_data_t reader_data_t::make_layout_data() const { assert(result.text.size() == result.colors.size()); result.position = focused_on_pager ? pager.cursor_position() : command_line.position(); result.selection = selection; - result.focused_on_pager = (active_edit_line() == &pager.search_field_line); + result.focused_on_pager = (active_edit_line() == pager.search_field_line()); result.history_search_range = history_search.search_range_if_active(); result.autosuggestion = autosuggestion.text; result.left_prompt_buff = left_prompt_buff; @@ -1073,7 +1074,7 @@ void reader_data_t::paint_layout(const wchar_t *reason) { // Prepend the mode prompt to the left prompt. screen->write(mode_prompt_buff + left_prompt_buff, right_prompt_buff, full_line, cmd_line->size(), *ffi_colors, indents, data.position, parser().vars_boxed(), - pager, current_page_rendering, data.focused_on_pager); + pager, *current_page_rendering, data.focused_on_pager); } /// Internal helper function for handling killing parts of text. @@ -1103,7 +1104,7 @@ void reader_data_t::command_line_changed(const editable_line_t *el) { if (el == &this->command_line) { // Update the gen count. s_generation.store(1 + read_generation_count(), std::memory_order_relaxed); - } else if (el == &this->pager.search_field_line) { + } else if (el == this->pager.search_field_line()) { if (history_pager_active) { fill_history_pager(history_pager_invocation_t::Anew, history_search_direction_t::Backward); @@ -1117,7 +1118,7 @@ void reader_data_t::command_line_changed(const editable_line_t *el) { } void reader_data_t::maybe_refilter_pager(const editable_line_t *el) { - if (el == &this->pager.search_field_line) { + if (el == this->pager.search_field_line()) { command_line_changed(el); } } @@ -1185,14 +1186,14 @@ void reader_data_t::fill_history_pager(history_pager_invocation_t why, old_pager_index = pager.selected_completion_index(); break; } - const wcstring search_term = *pager.search_field_line.text(); + const wcstring search_term = *pager.search_field_line()->text(); auto shared_this = this->shared_from_this(); std::function func = [=]() { return history_pager_search(**shared_this->history, direction, index, search_term); }; std::function completion = [=](const history_pager_result_t &result) { - if (search_term != *shared_this->pager.search_field_line.text()) + if (search_term != *shared_this->pager.search_field_line()->text()) return; // Stale request. if (result.matched_commands->empty() && why == history_pager_invocation_t::Advance) { // No more matches, keep the existing ones and flash. @@ -1207,8 +1208,8 @@ void reader_data_t::fill_history_pager(history_pager_invocation_t why, shared_this->history_pager_history_index_start = index; shared_this->history_pager_history_index_end = result.final_index; } - shared_this->pager.extra_progress_text = - result.have_more_results ? _(L"Search again for more results") : L""; + shared_this->pager.set_extra_progress_text( + result.have_more_results ? _(L"Search again for more results") : L""); shared_this->pager.set_completions(*result.matched_commands); if (why == history_pager_invocation_t::Refresh) { pager.set_selected_completion_index(*old_pager_index); @@ -1226,7 +1227,7 @@ void reader_data_t::fill_history_pager(history_pager_invocation_t why, void reader_data_t::pager_selection_changed() { ASSERT_IS_MAIN_THREAD(); - const completion_t *completion = this->pager.selected_completion(this->current_page_rendering); + const completion_t *completion = this->pager.selected_completion(*this->current_page_rendering); // Update the cursor and command line. size_t cursor_pos = this->cycle_cursor_pos; @@ -2036,7 +2037,8 @@ void reader_data_t::clear_pager() { void reader_data_t::select_completion_in_direction(selection_motion_t dir, bool force_selection_change) { - bool selection_changed = pager.select_next_completion_in_direction(dir, current_page_rendering); + bool selection_changed = + pager.select_next_completion_in_direction(dir, *current_page_rendering); if (force_selection_change || selection_changed) { pager_selection_changed(); } @@ -2288,7 +2290,7 @@ bool reader_data_t::handle_completions(const completion_list_t &comp, size_t tok } // Update the pager data. - pager.set_prefix(prefix); + pager.set_prefix(prefix, true); pager.set_completions(surviving_completions); // Modify the command line to reflect the new pager. pager_selection_changed(); @@ -2953,10 +2955,10 @@ bool check_exit_loop_maybe_warning(reader_data_t *data) { static bool selection_is_at_top(const reader_data_t *data) { const pager_t *pager = &data->pager; - size_t row = pager->get_selected_row(data->current_page_rendering); + size_t row = pager->get_selected_row(*data->current_page_rendering); if (row != 0 && row != PAGER_SELECTION_NONE) return false; - size_t col = pager->get_selected_column(data->current_page_rendering); + size_t col = pager->get_selected_column(*data->current_page_rendering); return !(col != 0 && col != PAGER_SELECTION_NONE); } @@ -2969,7 +2971,7 @@ void reader_data_t::update_commandline_state() const { } snapshot->selection = this->get_selection(); snapshot->pager_mode = !this->pager.empty(); - snapshot->pager_fully_disclosed = this->current_page_rendering.remaining_to_disclose == 0; + snapshot->pager_fully_disclosed = this->current_page_rendering->remaining_to_disclose() == 0; snapshot->search_mode = this->history_search.active(); snapshot->initialized = true; } @@ -3420,7 +3422,7 @@ void reader_data_t::handle_readline_command(readline_cmd_t c, readline_loop_stat (!rls.comp->empty() && !rls.complete_did_insert && rls.last_cmd == rl::complete)) { // The user typed complete more than once in a row. If we are not yet fully // disclosed, then become so; otherwise cycle through our available completions. - if (current_page_rendering.remaining_to_disclose > 0) { + if (current_page_rendering->remaining_to_disclose() > 0) { pager.set_fully_disclosed(); } else { select_completion_in_direction(c == rl::complete ? selection_motion_t::next @@ -3670,11 +3672,11 @@ void reader_data_t::handle_readline_command(readline_cmd_t c, readline_loop_stat pager.set_prefix(MB_CUR_MAX > 1 ? L"► " : L"> ", false /* highlight */); // Update the search field, which triggers the actual history search. if (!history_search.active() || history_search.search_string().empty()) { - insert_string(&pager.search_field_line, *command_line.text()); + insert_string(pager.search_field_line(), *command_line.text()); } else { // If we have an actual history search already going, reuse that term // - this is if the user looks around a bit and decides to switch to the pager. - insert_string(&pager.search_field_line, history_search.search_string()); + insert_string(pager.search_field_line(), history_search.search_string()); } break; } @@ -3684,7 +3686,7 @@ void reader_data_t::handle_readline_command(readline_cmd_t c, readline_loop_stat break; } inputter.function_set_status(true); - if (auto completion = pager.selected_completion(current_page_rendering)) { + if (auto completion = pager.selected_completion(*current_page_rendering)) { (*history)->remove(*completion->completion()); (*history)->save(); fill_history_pager(history_pager_invocation_t::Refresh, @@ -4268,9 +4270,9 @@ bool reader_data_t::handle_execute(readline_loop_state_t &rls) { if (this->history_pager_active && this->pager.selected_completion_index() == PAGER_SELECTION_NONE) { command_line.push_edit( - new_edit(0, command_line.size(), *this->pager.search_field_line.text()), + new_edit(0, command_line.size(), *this->pager.search_field_line()->text()), /* allow_coalesce */ false); - command_line.set_position(this->pager.search_field_line.position()); + command_line.set_position(this->pager.search_field_line()->position()); } clear_pager(); return true; @@ -4455,7 +4457,7 @@ maybe_t reader_data_t::readline(int nchars_or_0) { } // Clear the pager if necessary. - bool focused_on_search_field = (active_edit_line() == &pager.search_field_line); + bool focused_on_search_field = (active_edit_line() == pager.search_field_line()); if (!history_search.active() && command_ends_paging(readline_cmd, focused_on_search_field)) { clear_pager(); diff --git a/src/screen.h b/src/screen.h index 2ff2863c6..32779cb85 100644 --- a/src/screen.h +++ b/src/screen.h @@ -2,8 +2,10 @@ #define FISH_SCREEN_H #include "config.h" // IWYU pragma: keep -class pager_t; -class page_rendering_t; +struct PageRendering; +struct Pager; +using page_rendering_t = PageRendering; +using pager_t = Pager; #if INCLUDE_RUST_HEADERS #include "screen.rs.h"