From 3419afa7cf93d6ef0f7703a69192f076c0ddb2c5 Mon Sep 17 00:00:00 2001 From: Benjamin Sago Date: Thu, 22 Oct 2020 22:34:00 +0100 Subject: Massive theming and view options refactor This commit significantly refactors the way that options are parsed. It introduces the Theme type which contains both styling and extension configuration, converts the option-parsing process into a being a pure function, and removes some rather gnarly old code. The main purpose of the refactoring is to fix GH-318, "Tests fail when not connected to a terminal". Even though exa was compiling fine on my machine and on Travis, it was failing for automated build scripts. This was because of what the option-parsing code was trying to accomplish: it wasn't just providing a struct of the user's settings, it was also checking the terminal, providing a View directly. This has been changed so that the options module now _only_ looks at the command-line arguments and environment variables. Instead of returning a View, it returns the user's _preference_, and it's then up to the 'main' module to examine the terminal width and figure out if the view is doable, downgrading it if necessary. The code that used to determine the view was horrible and I'm pleased it can be cut out. Also, the terminal width used to be in a lazy_static because it was queried multiple times, and now it's not in one because it's only queried once, which is a good sign for things going in the right direction. There are also some naming and organisational changes around themes. The blanket terms "Colours" and "Styles" have been yeeted in favour of "Theme", which handles both extensions and UI colours. The FileStyle struct has been replaced with file_name::Options, making it similar to the views in how it has an Options struct and a Render struct. Finally, eight unit tests have been removed because they turned out to be redundant (testing --colour and --color) after examining the tangled code, and the default theme has been put in its own file in preparation for more themes. --- src/info/filetype.rs | 2 +- src/main.rs | 60 +++-- src/options/file_name.rs | 21 ++ src/options/mod.rs | 15 +- src/options/style.rs | 551 --------------------------------------------- src/options/theme.rs | 159 +++++++++++++ src/options/view.rs | 209 ++++++----------- src/output/details.rs | 24 +- src/output/file_name.rs | 86 ++----- src/output/grid.rs | 29 ++- src/output/grid_details.rs | 43 ++-- src/output/icons.rs | 37 +-- src/output/lines.rs | 21 +- src/output/mod.rs | 32 ++- src/output/table.rs | 36 +-- src/style/colours.rs | 471 -------------------------------------- src/style/lsc.rs | 234 ------------------- src/style/mod.rs | 6 - src/theme/default_theme.rs | 130 +++++++++++ src/theme/lsc.rs | 235 +++++++++++++++++++ src/theme/mod.rs | 528 +++++++++++++++++++++++++++++++++++++++++++ src/theme/ui_styles.rs | 217 ++++++++++++++++++ 22 files changed, 1576 insertions(+), 1570 deletions(-) create mode 100644 src/options/file_name.rs delete mode 100644 src/options/style.rs create mode 100644 src/options/theme.rs delete mode 100644 src/style/colours.rs delete mode 100644 src/style/lsc.rs delete mode 100644 src/style/mod.rs create mode 100644 src/theme/default_theme.rs create mode 100644 src/theme/lsc.rs create mode 100644 src/theme/mod.rs create mode 100644 src/theme/ui_styles.rs diff --git a/src/info/filetype.rs b/src/info/filetype.rs index 83f33f6..38b1ac0 100644 --- a/src/info/filetype.rs +++ b/src/info/filetype.rs @@ -7,8 +7,8 @@ use ansi_term::Style; use crate::fs::File; -use crate::output::file_name::FileColours; use crate::output::icons::FileIcon; +use crate::theme::FileColours; #[derive(Debug, Default, PartialEq)] diff --git a/src/main.rs b/src/main.rs index 3f94d4c..0e2fbbb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,13 +35,14 @@ use crate::fs::feature::git::GitCache; use crate::fs::filter::GitIgnore; use crate::options::{Options, Vars, vars, OptionsResult}; use crate::output::{escape, lines, grid, grid_details, details, View, Mode}; +use crate::theme::Theme; mod fs; mod info; mod logger; mod options; mod output; -mod style; +mod theme; fn main() { @@ -61,7 +62,10 @@ fn main() { let git = git_options(&options, &input_paths); let writer = io::stdout(); - let exa = Exa { options, writer, input_paths, git }; + + let console_width = options.view.width.actual_terminal_width(); + let theme = options.theme.to_theme(console_width.is_some()); + let exa = Exa { options, writer, input_paths, theme, console_width, git }; match exa.run() { Ok(exit_status) => { @@ -114,6 +118,15 @@ pub struct Exa<'args> { /// names (anything that isn’t an option). pub input_paths: Vec<&'args OsStr>, + /// The theme that has been configured from the command-line options and + /// environment variables. If colours are disabled, this is a theme with + /// every style set to the default. + pub theme: Theme, + + /// The detected width of the console. This is used to determine which + /// view to use. + pub console_width: Option, + /// A global Git cache, if the option was passed in. /// This has to last the lifetime of the program, because the user might /// want to list several directories in the same repository. @@ -241,45 +254,62 @@ impl<'args> Exa<'args> { } /// Prints the list of files using whichever view is selected. - /// For various annoying logistical reasons, each one handles - /// printing differently... fn print_files(&mut self, dir: Option<&Dir>, files: Vec>) -> io::Result<()> { if files.is_empty() { return Ok(()); } - let View { ref mode, ref colours, ref style } = self.options.view; + let theme = &self.theme; + let View { ref mode, ref file_style, .. } = self.options.view; - match mode { - Mode::Lines(ref opts) => { - let r = lines::Render { files, colours, style, opts }; + match (mode, self.console_width) { + (Mode::Grid(ref opts), Some(console_width)) => { + let r = grid::Render { files, theme, file_style, opts, console_width }; r.render(&mut self.writer) } - Mode::Grid(ref opts) => { - let r = grid::Render { files, colours, style, opts }; + (Mode::Grid(ref opts), None) => { + let opts = &opts.to_lines_options(); + let r = lines::Render { files, theme, file_style, opts }; r.render(&mut self.writer) } - Mode::Details(ref opts) => { + (Mode::Lines(ref opts), _) => { + let r = lines::Render { files, theme, file_style, opts }; + r.render(&mut self.writer) + } + + (Mode::Details(ref opts), _) => { let filter = &self.options.filter; let recurse = self.options.dir_action.recurse_options(); let git_ignoring = self.options.filter.git_ignore == GitIgnore::CheckAndIgnore; let git = self.git.as_ref(); - let r = details::Render { dir, files, colours, style, opts, filter, recurse, git_ignoring, git }; + let r = details::Render { dir, files, theme, file_style, opts, filter, recurse, git_ignoring, git }; r.render(&mut self.writer) } - Mode::GridDetails(ref opts) => { + (Mode::GridDetails(ref opts), Some(console_width)) => { let grid = &opts.grid; - let filter = &self.options.filter; let details = &opts.details; let row_threshold = opts.row_threshold; + let filter = &self.options.filter; let git_ignoring = self.options.filter.git_ignore == GitIgnore::CheckAndIgnore; let git = self.git.as_ref(); - let r = grid_details::Render { dir, files, colours, style, grid, details, filter, row_threshold, git_ignoring, git }; + + let r = grid_details::Render { dir, files, theme, file_style, grid, details, filter, row_threshold, git_ignoring, git, console_width }; + r.render(&mut self.writer) + } + + (Mode::GridDetails(ref opts), None) => { + let opts = &opts.to_details_options(); + let filter = &self.options.filter; + let recurse = self.options.dir_action.recurse_options(); + let git_ignoring = self.options.filter.git_ignore == GitIgnore::CheckAndIgnore; + + let git = self.git.as_ref(); + let r = details::Render { dir, files, theme, file_style, opts, filter, recurse, git_ignoring, git }; r.render(&mut self.writer) } } diff --git a/src/options/file_name.rs b/src/options/file_name.rs new file mode 100644 index 0000000..9092b8c --- /dev/null +++ b/src/options/file_name.rs @@ -0,0 +1,21 @@ +use crate::options::{flags, OptionsError}; +use crate::options::parser::MatchedFlags; + +use crate::output::file_name::{Options, Classify}; + + +impl Options { + pub fn deduce(matches: &MatchedFlags<'_>) -> Result { + Classify::deduce(matches) + .map(|classify| Self { classify }) + } +} + +impl Classify { + fn deduce(matches: &MatchedFlags<'_>) -> Result { + let flagged = matches.has(&flags::CLASSIFY)?; + + if flagged { Ok(Self::AddFileIndicators) } + else { Ok(Self::JustFilenames) } + } +} diff --git a/src/options/mod.rs b/src/options/mod.rs index 324bfe8..2565d28 100644 --- a/src/options/mod.rs +++ b/src/options/mod.rs @@ -74,11 +74,13 @@ use std::ffi::OsStr; use crate::fs::dir_action::DirAction; use crate::fs::filter::{FileFilter, GitIgnore}; use crate::output::{View, Mode, details, grid_details}; +use crate::theme::Options as ThemeOptions; mod dir_action; +mod file_name; mod filter; mod flags; -mod style; +mod theme; mod view; mod error; @@ -109,8 +111,14 @@ pub struct Options { /// How to sort and filter files before outputting them. pub filter: FileFilter, - /// The type of output to use (lines, grid, or details). + /// The user’s preference of view to use (lines, grid, details, or + /// grid-details) along with the options on how to render file names. + /// If the view requires the terminal to have a width, and there is no + /// width, then the view will be downgraded. pub view: View, + + /// The options to make up the styles of the UI and file names. + pub theme: ThemeOptions, } impl Options { @@ -171,8 +179,9 @@ impl Options { let dir_action = DirAction::deduce(matches)?; let filter = FileFilter::deduce(matches)?; let view = View::deduce(matches, vars)?; + let theme = ThemeOptions::deduce(matches, vars)?; - Ok(Self { dir_action, view, filter }) + Ok(Self { dir_action, filter, view, theme }) } } diff --git a/src/options/style.rs b/src/options/style.rs deleted file mode 100644 index 0a15408..0000000 --- a/src/options/style.rs +++ /dev/null @@ -1,551 +0,0 @@ -use ansi_term::Style; - -use crate::fs::File; -use crate::options::{flags, Vars, OptionsError}; -use crate::options::parser::MatchedFlags; -use crate::output::file_name::{Classify, FileStyle}; -use crate::style::Colours; - - -/// Under what circumstances we should display coloured, rather than plain, -/// output to the terminal. -/// -/// By default, we want to display the colours when stdout can display them. -/// Turning them on when output is going to, say, a pipe, would make programs -/// such as `grep` or `more` not work properly. So the `Automatic` mode does -/// this check and only displays colours when they can be truly appreciated. -#[derive(PartialEq, Debug)] -enum TerminalColours { - - /// Display them even when output isn’t going to a terminal. - Always, - - /// Display them when output is going to a terminal, but not otherwise. - Automatic, - - /// Never display them, even when output is going to a terminal. - Never, -} - -impl Default for TerminalColours { - fn default() -> Self { - Self::Automatic - } -} - - -impl TerminalColours { - - /// Determine which terminal colour conditions to use. - fn deduce(matches: &MatchedFlags<'_>) -> Result { - let word = match matches.get_where(|f| f.matches(&flags::COLOR) || f.matches(&flags::COLOUR))? { - Some(w) => w, - None => return Ok(Self::default()), - }; - - if word == "always" { - Ok(Self::Always) - } - else if word == "auto" || word == "automatic" { - Ok(Self::Automatic) - } - else if word == "never" { - Ok(Self::Never) - } - else { - Err(OptionsError::BadArgument(&flags::COLOR, word.into())) - } - } -} - - -/// **Styles**, which is already an overloaded term, is a pair of view option -/// sets that happen to both be affected by `LS_COLORS` and `EXA_COLORS`. -/// Because it’s better to only iterate through that once, the two are deduced -/// together. -pub struct Styles { - - /// The colours to paint user interface elements, like the date column, - /// and file kinds, such as directories. - pub colours: Colours, - - /// The colours to paint the names of files that match glob patterns - /// (and the classify option). - pub style: FileStyle, -} - -impl Styles { - - #[allow(trivial_casts)] // the `as Box<_>` stuff below warns about this for some reason - pub fn deduce(matches: &MatchedFlags<'_>, vars: &V, widther: TW) -> Result - where TW: Fn() -> Option, V: Vars { - use crate::info::filetype::FileExtensions; - use crate::output::file_name::NoFileColours; - - let classify = Classify::deduce(matches)?; - - // Before we do anything else, figure out if we need to consider - // custom colours at all - let tc = TerminalColours::deduce(matches)?; - - if tc == TerminalColours::Never || (tc == TerminalColours::Automatic && widther().is_none()) { - let exts = Box::new(NoFileColours); - - return Ok(Self { - colours: Colours::plain(), - style: FileStyle { classify, exts }, - }); - } - - // Parse the environment variables into colours and extension mappings - let scale = matches.has_where(|f| f.matches(&flags::COLOR_SCALE) || f.matches(&flags::COLOUR_SCALE))?; - let mut colours = Colours::colourful(scale.is_some()); - - let (exts, use_default_filetypes) = parse_color_vars(vars, &mut colours); - - // Use between 0 and 2 file name highlighters - let exts = match (exts.is_non_empty(), use_default_filetypes) { - (false, false) => Box::new(NoFileColours) as Box<_>, - (false, true) => Box::new(FileExtensions) as Box<_>, - ( true, false) => Box::new(exts) as Box<_>, - ( true, true) => Box::new((exts, FileExtensions)) as Box<_>, - }; - - let style = FileStyle { classify, exts }; - Ok(Self { colours, style }) - } -} - -/// Parse the environment variables into `LS_COLORS` pairs, putting file glob -/// colours into the `ExtensionMappings` that gets returned, and using the -/// two-character UI codes to modify the mutable `Colours`. -/// -/// Also returns if the `EXA_COLORS` variable should reset the existing file -/// type mappings or not. The `reset` code needs to be the first one. -fn parse_color_vars(vars: &V, colours: &mut Colours) -> (ExtensionMappings, bool) { - use log::*; - - use crate::options::vars; - use crate::style::LSColors; - - let mut exts = ExtensionMappings::default(); - - if let Some(lsc) = vars.get(vars::LS_COLORS) { - let lsc = lsc.to_string_lossy(); - - LSColors(lsc.as_ref()).each_pair(|pair| { - if ! colours.set_ls(&pair) { - match glob::Pattern::new(pair.key) { - Ok(pat) => { - exts.add(pat, pair.to_style()); - } - Err(e) => { - warn!("Couldn't parse glob pattern {:?}: {}", pair.key, e); - } - } - } - }); - } - - let mut use_default_filetypes = true; - - if let Some(exa) = vars.get(vars::EXA_COLORS) { - let exa = exa.to_string_lossy(); - - // Is this hacky? Yes. - if exa == "reset" || exa.starts_with("reset:") { - use_default_filetypes = false; - } - - LSColors(exa.as_ref()).each_pair(|pair| { - if ! colours.set_ls(&pair) && ! colours.set_exa(&pair) { - match glob::Pattern::new(pair.key) { - Ok(pat) => { - exts.add(pat, pair.to_style()); - } - Err(e) => { - warn!("Couldn't parse glob pattern {:?}: {}", pair.key, e); - } - } - }; - }); - } - - (exts, use_default_filetypes) -} - - -#[derive(PartialEq, Debug, Default)] -struct ExtensionMappings { - mappings: Vec<(glob::Pattern, Style)>, -} - -// Loop through backwards so that colours specified later in the list override -// colours specified earlier, like we do with options and strict mode - -use crate::output::file_name::FileColours; -impl FileColours for ExtensionMappings { - fn colour_file(&self, file: &File<'_>) -> Option