From 7df6317e243cdb24669e9d82146a875dbf804860 Mon Sep 17 00:00:00 2001 From: LazyTanuki <43273245+lazytanuki@users.noreply.github.com> Date: Fri, 10 Mar 2023 16:45:05 +0100 Subject: [PATCH 1/8] wip: generalised `load_theme` into `load_inheritable_toml` --- helix-view/src/theme.rs | 89 +++++++++++++++++++++++++++-------------- 1 file changed, 59 insertions(+), 30 deletions(-) diff --git a/helix-view/src/theme.rs b/helix-view/src/theme.rs index a8cc59260291..327389a16ee9 100644 --- a/helix-view/src/theme.rs +++ b/helix-view/src/theme.rs @@ -61,7 +61,19 @@ impl Loader { } let mut visited_paths = HashSet::new(); - let theme = self.load_theme(name, &mut visited_paths).map(Theme::from)?; + let default_themes = HashMap::from([ + ("default", &DEFAULT_THEME_DATA), + ("base16_default", &BASE16_DEFAULT_THEME_DATA), + ]); + let theme = self + .load_inheritable_toml( + name, + &self.theme_dirs, + &mut visited_paths, + &default_themes, + Self::merge_themes, + ) + .map(Theme::from)?; Ok(Theme { name: name.into(), @@ -69,43 +81,55 @@ impl Loader { }) } - /// Recursively load a theme, merging with any inherited parent themes. + /// Recursively load a TOML document, merging with any inherited parent files. /// /// The paths that have been visited in the inheritance hierarchy are tracked /// to detect and avoid cycling. /// /// It is possible for one file to inherit from another file with the same name - /// so long as the second file is in a themes directory with lower priority. + /// so long as the second file is in a search directory with lower priority. /// However, it is not recommended that users do this as it will make tracing /// errors more difficult. - fn load_theme(&self, name: &str, visited_paths: &mut HashSet) -> Result { - let path = self.path(name, visited_paths)?; - - let theme_toml = self.load_toml(path)?; - - let inherits = theme_toml.get("inherits"); - - let theme_toml = if let Some(parent_theme_name) = inherits { - let parent_theme_name = parent_theme_name.as_str().ok_or_else(|| { + fn load_inheritable_toml( + &self, + name: &str, + search_directories: &[PathBuf], + visited_paths: &mut HashSet, + default_toml_data: &HashMap<&str, &Lazy>, + merge_toml_docs: fn(Value, Value) -> Value, + ) -> Result { + let path = self.path(name, search_directories, visited_paths)?; + + let toml_doc = self.load_toml(&path)?; + + let inherits = toml_doc.get("inherits"); + + let toml_doc = if let Some(parent_toml_name) = inherits { + let parent_toml_name = parent_toml_name.as_str().ok_or_else(|| { anyhow!( - "Theme: expected 'inherits' to be a string: {}", - parent_theme_name + "{:?}: expected 'inherits' to be a string: {}", + path, + parent_toml_name ) })?; - let parent_theme_toml = match parent_theme_name { - // load default themes's toml from const. - "default" => DEFAULT_THEME_DATA.clone(), - "base16_default" => BASE16_DEFAULT_THEME_DATA.clone(), - _ => self.load_theme(parent_theme_name, visited_paths)?, + let parent_toml_doc = match default_toml_data.get(parent_toml_name) { + Some(p) => (**p).clone(), + None => self.load_inheritable_toml( + parent_toml_name, + search_directories, + visited_paths, + default_toml_data, + merge_toml_docs, + )?, }; - self.merge_themes(parent_theme_toml, theme_toml) + merge_toml_docs(parent_toml_doc, toml_doc) } else { - theme_toml + toml_doc }; - Ok(theme_toml) + Ok(toml_doc) } pub fn read_names(path: &Path) -> Vec { @@ -124,7 +148,7 @@ impl Loader { } // merge one theme into the parent theme - fn merge_themes(&self, parent_theme_toml: Value, theme_toml: Value) -> Value { + fn merge_themes(parent_theme_toml: Value, theme_toml: Value) -> Value { let parent_palette = parent_theme_toml.get("palette"); let palette = theme_toml.get("palette"); @@ -149,22 +173,27 @@ impl Loader { merge_toml_values(theme, palette.into(), 1) } - // Loads the theme data as `toml::Value` - fn load_toml(&self, path: PathBuf) -> Result { + // Loads the TOML data as `toml::Value` + fn load_toml(&self, path: &Path) -> Result { let data = std::fs::read_to_string(path)?; let value = toml::from_str(&data)?; Ok(value) } - /// Returns the path to the theme with the given name + /// Returns the path to the TOML document with the given name /// /// Ignores paths already visited and follows directory priority order. - fn path(&self, name: &str, visited_paths: &mut HashSet) -> Result { + fn path( + &self, + name: &str, + search_directories: &[PathBuf], + visited_paths: &mut HashSet, + ) -> Result { let filename = format!("{}.toml", name); let mut cycle_found = false; // track if there was a path, but it was in a cycle - self.theme_dirs + search_directories .iter() .find_map(|dir| { let path = dir.join(&filename); @@ -181,9 +210,9 @@ impl Loader { }) .ok_or_else(|| { if cycle_found { - anyhow!("Theme: cycle found in inheriting: {}", name) + anyhow!("Toml: cycle found in inheriting: {}", name) } else { - anyhow!("Theme: file not found for: {}", name) + anyhow!("Toml: file not found for: {}", name) } }) } From 5dce3544e80984aef55830552f99895ec9f0a714 Mon Sep 17 00:00:00 2001 From: LazyTanuki <43273245+lazytanuki@users.noreply.github.com> Date: Fri, 10 Mar 2023 17:17:11 +0100 Subject: [PATCH 2/8] =?UTF-8?q?wip:=20moved=20`load=5Finheritable=5Ftoml`,?= =?UTF-8?q?=20`path`=20(=E2=86=92=20`get=5Ftoml=5Fpath`),=20and=20`load=5F?= =?UTF-8?q?toml`=20from=20theme.rs=20to=20the=20`helix-loader`=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helix-loader/src/lib.rs | 103 ++++++++++++++++++++++++++++++++++-- helix-view/src/theme.rs | 114 ++++------------------------------------ 2 files changed, 109 insertions(+), 108 deletions(-) diff --git a/helix-loader/src/lib.rs b/helix-loader/src/lib.rs index ad4ad899db67..dcda49aad84a 100644 --- a/helix-loader/src/lib.rs +++ b/helix-loader/src/lib.rs @@ -1,8 +1,14 @@ pub mod config; pub mod grammar; +use anyhow::{anyhow, Result}; use etcetera::base_strategy::{choose_base_strategy, BaseStrategy}; -use std::path::{Path, PathBuf}; +use once_cell::sync::Lazy; +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, +}; +use toml::Value; pub const VERSION_AND_GIT_HASH: &str = env!("VERSION_AND_GIT_HASH"); @@ -154,8 +160,6 @@ pub fn log_file() -> PathBuf { /// where one usually wants to override or add to the array instead of /// replacing it altogether. pub fn merge_toml_values(left: toml::Value, right: toml::Value, merge_depth: usize) -> toml::Value { - use toml::Value; - fn get_name(v: &Value) -> Option<&str> { v.get("name").and_then(Value::as_str) } @@ -227,6 +231,99 @@ pub fn find_workspace() -> (PathBuf, bool) { (current_dir, true) } +/// Recursively load a TOML document, merging with any inherited parent files. +/// +/// The paths that have been visited in the inheritance hierarchy are tracked +/// to detect and avoid cycling. +/// +/// It is possible for one file to inherit from another file with the same name +/// so long as the second file is in a search directory with lower priority. +/// However, it is not recommended that users do this as it will make tracing +/// errors more difficult. +pub fn load_inheritable_toml( + name: &str, + search_directories: &[PathBuf], + visited_paths: &mut HashSet, + default_toml_data: &HashMap<&str, &Lazy>, + merge_toml_docs: fn(Value, Value) -> Value, +) -> Result { + let path = get_toml_path(name, search_directories, visited_paths)?; + + let toml_doc = load_toml(&path)?; + + let inherits = toml_doc.get("inherits"); + + let toml_doc = if let Some(parent_toml_name) = inherits { + let parent_toml_name = parent_toml_name.as_str().ok_or_else(|| { + anyhow!( + "{:?}: expected 'inherits' to be a string: {}", + path, + parent_toml_name + ) + })?; + + let parent_toml_doc = match default_toml_data.get(parent_toml_name) { + Some(p) => (**p).clone(), + None => load_inheritable_toml( + parent_toml_name, + search_directories, + visited_paths, + default_toml_data, + merge_toml_docs, + )?, + }; + + merge_toml_docs(parent_toml_doc, toml_doc) + } else { + toml_doc + }; + + Ok(toml_doc) +} + +/// Returns the path to the TOML document with the given name +/// +/// Ignores paths already visited and follows directory priority order. +fn get_toml_path( + name: &str, + search_directories: &[PathBuf], + visited_paths: &mut HashSet, +) -> Result { + let filename = format!("{}.toml", name); + + let mut cycle_found = false; // track if there was a path, but it was in a cycle + search_directories + .iter() + .find_map(|dir| { + let path = dir.join(&filename); + if !path.exists() { + None + } else if visited_paths.contains(&path) { + // Avoiding cycle, continuing to look in lower priority directories + cycle_found = true; + None + } else { + visited_paths.insert(path.clone()); + Some(path) + } + }) + .ok_or_else(|| { + if cycle_found { + anyhow!("Toml: cycle found in inheriting: {}", name) + } else { + anyhow!("Toml: file not found for: {}", name) + } + }) +} + +// Loads the TOML data as `toml::Value` +fn load_toml(path: &Path) -> Result { + let data = std::fs::read_to_string(path)?; + let value = toml::from_str(&data)?; + + Ok(value) +} + #[cfg(test)] mod merge_toml_tests { use std::str; diff --git a/helix-view/src/theme.rs b/helix-view/src/theme.rs index 327389a16ee9..47bcc4b87e2e 100644 --- a/helix-view/src/theme.rs +++ b/helix-view/src/theme.rs @@ -4,7 +4,7 @@ use std::{ str, }; -use anyhow::{anyhow, Result}; +use anyhow::Result; use helix_core::hashmap; use helix_loader::merge_toml_values; use log::warn; @@ -65,15 +65,14 @@ impl Loader { ("default", &DEFAULT_THEME_DATA), ("base16_default", &BASE16_DEFAULT_THEME_DATA), ]); - let theme = self - .load_inheritable_toml( - name, - &self.theme_dirs, - &mut visited_paths, - &default_themes, - Self::merge_themes, - ) - .map(Theme::from)?; + let theme = helix_loader::load_inheritable_toml( + name, + &self.theme_dirs, + &mut visited_paths, + &default_themes, + Self::merge_themes, + ) + .map(Theme::from)?; Ok(Theme { name: name.into(), @@ -81,57 +80,6 @@ impl Loader { }) } - /// Recursively load a TOML document, merging with any inherited parent files. - /// - /// The paths that have been visited in the inheritance hierarchy are tracked - /// to detect and avoid cycling. - /// - /// It is possible for one file to inherit from another file with the same name - /// so long as the second file is in a search directory with lower priority. - /// However, it is not recommended that users do this as it will make tracing - /// errors more difficult. - fn load_inheritable_toml( - &self, - name: &str, - search_directories: &[PathBuf], - visited_paths: &mut HashSet, - default_toml_data: &HashMap<&str, &Lazy>, - merge_toml_docs: fn(Value, Value) -> Value, - ) -> Result { - let path = self.path(name, search_directories, visited_paths)?; - - let toml_doc = self.load_toml(&path)?; - - let inherits = toml_doc.get("inherits"); - - let toml_doc = if let Some(parent_toml_name) = inherits { - let parent_toml_name = parent_toml_name.as_str().ok_or_else(|| { - anyhow!( - "{:?}: expected 'inherits' to be a string: {}", - path, - parent_toml_name - ) - })?; - - let parent_toml_doc = match default_toml_data.get(parent_toml_name) { - Some(p) => (**p).clone(), - None => self.load_inheritable_toml( - parent_toml_name, - search_directories, - visited_paths, - default_toml_data, - merge_toml_docs, - )?, - }; - - merge_toml_docs(parent_toml_doc, toml_doc) - } else { - toml_doc - }; - - Ok(toml_doc) - } - pub fn read_names(path: &Path) -> Vec { std::fs::read_dir(path) .map(|entries| { @@ -173,50 +121,6 @@ impl Loader { merge_toml_values(theme, palette.into(), 1) } - // Loads the TOML data as `toml::Value` - fn load_toml(&self, path: &Path) -> Result { - let data = std::fs::read_to_string(path)?; - let value = toml::from_str(&data)?; - - Ok(value) - } - - /// Returns the path to the TOML document with the given name - /// - /// Ignores paths already visited and follows directory priority order. - fn path( - &self, - name: &str, - search_directories: &[PathBuf], - visited_paths: &mut HashSet, - ) -> Result { - let filename = format!("{}.toml", name); - - let mut cycle_found = false; // track if there was a path, but it was in a cycle - search_directories - .iter() - .find_map(|dir| { - let path = dir.join(&filename); - if !path.exists() { - None - } else if visited_paths.contains(&path) { - // Avoiding cycle, continuing to look in lower priority directories - cycle_found = true; - None - } else { - visited_paths.insert(path.clone()); - Some(path) - } - }) - .ok_or_else(|| { - if cycle_found { - anyhow!("Toml: cycle found in inheriting: {}", name) - } else { - anyhow!("Toml: file not found for: {}", name) - } - }) - } - pub fn default_theme(&self, true_color: bool) -> Theme { if true_color { self.default() From 82c67104d7f8dea0ad850cd3ca31444da22e7bc5 Mon Sep 17 00:00:00 2001 From: LazyTanuki <43273245+lazytanuki@users.noreply.github.com> Date: Fri, 10 Mar 2023 17:23:06 +0100 Subject: [PATCH 3/8] wip: documented and moved `theme::Loader::read_names` to `helix_loader::read_toml_names` --- helix-loader/src/lib.rs | 16 ++++++++++++++++ helix-term/src/ui/mod.rs | 5 ++--- helix-view/src/theme.rs | 17 +---------------- xtask/src/themelint.rs | 6 ++---- 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/helix-loader/src/lib.rs b/helix-loader/src/lib.rs index dcda49aad84a..f7178e34a453 100644 --- a/helix-loader/src/lib.rs +++ b/helix-loader/src/lib.rs @@ -324,6 +324,22 @@ fn load_toml(path: &Path) -> Result { Ok(value) } +/// Returns the names of the TOML documents within a directory +pub fn read_toml_names(path: &Path) -> Vec { + std::fs::read_dir(path) + .map(|entries| { + entries + .filter_map(|entry| { + let entry = entry.ok()?; + let path = entry.path(); + (path.extension()? == "toml") + .then(|| path.file_stem().unwrap().to_string_lossy().into_owned()) + }) + .collect() + }) + .unwrap_or_default() +} + #[cfg(test)] mod merge_toml_tests { use std::str; diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index ec328ec55cea..a43f4fadc485 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -240,7 +240,6 @@ pub mod completers { use fuzzy_matcher::FuzzyMatcher; use helix_core::syntax::LanguageServerFeature; use helix_view::document::SCRATCH_BUFFER_NAME; - use helix_view::theme; use helix_view::{editor::Config, Editor}; use once_cell::sync::Lazy; use std::borrow::Cow; @@ -281,9 +280,9 @@ pub mod completers { } pub fn theme(_editor: &Editor, input: &str) -> Vec { - let mut names = theme::Loader::read_names(&helix_loader::config_dir().join("themes")); + let mut names = helix_loader::read_toml_names(&helix_loader::config_dir().join("themes")); for rt_dir in helix_loader::runtime_dirs() { - names.extend(theme::Loader::read_names(&rt_dir.join("themes"))); + names.extend(helix_loader::read_toml_names(&rt_dir.join("themes"))); } names.push("default".into()); names.push("base16_default".into()); diff --git a/helix-view/src/theme.rs b/helix-view/src/theme.rs index 47bcc4b87e2e..78e814351553 100644 --- a/helix-view/src/theme.rs +++ b/helix-view/src/theme.rs @@ -1,6 +1,6 @@ use std::{ collections::{HashMap, HashSet}, - path::{Path, PathBuf}, + path::PathBuf, str, }; @@ -80,21 +80,6 @@ impl Loader { }) } - pub fn read_names(path: &Path) -> Vec { - std::fs::read_dir(path) - .map(|entries| { - entries - .filter_map(|entry| { - let entry = entry.ok()?; - let path = entry.path(); - (path.extension()? == "toml") - .then(|| path.file_stem().unwrap().to_string_lossy().into_owned()) - }) - .collect() - }) - .unwrap_or_default() - } - // merge one theme into the parent theme fn merge_themes(parent_theme_toml: Value, theme_toml: Value) -> Value { let parent_palette = parent_theme_toml.get("palette"); diff --git a/xtask/src/themelint.rs b/xtask/src/themelint.rs index f7efb7d9a9ad..16a6c841a087 100644 --- a/xtask/src/themelint.rs +++ b/xtask/src/themelint.rs @@ -1,8 +1,6 @@ use crate::path; use crate::DynError; -use helix_view::theme::Loader; -use helix_view::theme::Modifier; -use helix_view::Theme; +use helix_view::{theme::Modifier, Theme}; struct Rule { fg: Option<&'static str>, @@ -180,7 +178,7 @@ pub fn lint(file: String) -> Result<(), DynError> { } pub fn lint_all() -> Result<(), DynError> { - let files = Loader::read_names(path::themes().as_path()); + let files = helix_loader::read_toml_names(path::themes().as_path()); let files_count = files.len(); let ok_files_count = files .into_iter() From d994e17fdfefcc68ae3647c8b6915fbe2ca08384 Mon Sep 17 00:00:00 2001 From: LazyTanuki <43273245+lazytanuki@users.noreply.github.com> Date: Fri, 10 Mar 2023 17:46:17 +0100 Subject: [PATCH 4/8] wip: add the `icons` module as well as default and nerdfonts flavors --- helix-view/src/icons.rs | 299 +++++++++++++++++++++++++++++++++++ helix-view/src/lib.rs | 1 + icons.toml | 18 +++ runtime/icons/nerdfonts.toml | 284 +++++++++++++++++++++++++++++++++ 4 files changed, 602 insertions(+) create mode 100644 helix-view/src/icons.rs create mode 100644 icons.toml create mode 100644 runtime/icons/nerdfonts.toml diff --git a/helix-view/src/icons.rs b/helix-view/src/icons.rs new file mode 100644 index 000000000000..3e1ebb4d35c7 --- /dev/null +++ b/helix-view/src/icons.rs @@ -0,0 +1,299 @@ +use helix_loader::merge_toml_values; +use log::warn; +use once_cell::sync::Lazy; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +use std::{ + path::{Path, PathBuf}, + str, +}; +use toml::Value; + +use crate::graphics::{Color, Style}; +use crate::Theme; + +pub static BLANK_ICON: Icon = Icon { + icon_char: ' ', + style: None, +}; + +/// The style of an icon can either be defined by the TOML file, or by the theme. +/// We need to remember that in order to reload the icons colors when the theme changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IconStyle { + Custom(Style), + Default(Style), +} + +impl Default for IconStyle { + fn default() -> Self { + IconStyle::Default(Style::default()) + } +} + +impl From for Style { + fn from(icon_style: IconStyle) -> Self { + match icon_style { + IconStyle::Custom(style) => style, + IconStyle::Default(style) => style, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct Icon { + #[serde(rename = "icon")] + pub icon_char: char, + #[serde(default)] + #[serde(deserialize_with = "icon_color_to_style", rename = "color")] + pub style: Option, +} + +impl Icon { + /// Loads a given style if the icon style is undefined or based on a default value + pub fn with_default_style(&mut self, style: Style) { + if self.style.is_none() || matches!(self.style, Some(IconStyle::Default(_))) { + self.style = Some(IconStyle::Default(style)); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct Icons { + pub name: String, + pub mime_type: Option>, + pub diagnostic: Diagnostic, + pub symbol_kind: Option>, + pub breakpoint: Breakpoint, + pub diff: Diff, + pub ui: Option>, +} + +impl Icons { + pub fn name(&self) -> &str { + &self.name + } + + /// Set theme defined styles to diagnostic icons + pub fn set_diagnostic_icons_base_style(&mut self, theme: &Theme) { + self.diagnostic.error.with_default_style(theme.get("error")); + self.diagnostic.info.with_default_style(theme.get("info")); + self.diagnostic.hint.with_default_style(theme.get("hint")); + self.diagnostic + .warning + .with_default_style(theme.get("warning")); + } + + /// Set theme defined styles to symbol-kind icons + pub fn set_symbolkind_icons_base_style(&mut self, theme: &Theme) { + let style = theme + .try_get("symbolkind") + .unwrap_or_else(|| theme.get("keyword")); + if let Some(symbol_kind_icons) = &mut self.symbol_kind { + for (_, icon) in symbol_kind_icons.iter_mut() { + icon.with_default_style(style); + } + } + } + + /// Set the default style for all icons + pub fn reset_styles(&mut self) { + if let Some(mime_type_icons) = &mut self.mime_type { + for (_, icon) in mime_type_icons.iter_mut() { + icon.style = Some(IconStyle::Default(Style::default())); + } + } + if let Some(symbol_kind_icons) = &mut self.symbol_kind { + for (_, icon) in symbol_kind_icons.iter_mut() { + icon.style = Some(IconStyle::Default(Style::default())); + } + } + if let Some(ui_icons) = &mut self.ui { + for (_, icon) in ui_icons.iter_mut() { + icon.style = Some(IconStyle::Default(Style::default())); + } + } + self.diagnostic.error.style = Some(IconStyle::Default(Style::default())); + self.diagnostic.warning.style = Some(IconStyle::Default(Style::default())); + self.diagnostic.hint.style = Some(IconStyle::Default(Style::default())); + self.diagnostic.info.style = Some(IconStyle::Default(Style::default())); + } + + pub fn icon_from_filetype<'a>(&'a self, filetype: &str) -> Option<&'a Icon> { + if let Some(mime_type_icons) = &self.mime_type { + mime_type_icons.get(filetype) + } else { + None + } + } + + /// Try to return a reference to an appropriate icon for the specified file path, with a default "file" icon if none is found. + /// If no such "file" icon is available, return `None`. + pub fn icon_from_path<'a>(&'a self, filepath: Option<&PathBuf>) -> Option<&'a Icon> { + self.mime_type + .as_ref() + .and_then(|mime_type_icons| { + filepath? + .extension() + .or(filepath?.file_name()) + .map(|extension_or_filename| extension_or_filename.to_str())? + .and_then(|extension_or_filename| mime_type_icons.get(extension_or_filename)) + }) + .or_else(|| self.ui.as_ref().and_then(|ui_icons| ui_icons.get("file"))) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct Diagnostic { + pub error: Icon, + pub warning: Icon, + pub info: Icon, + pub hint: Icon, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct Breakpoint { + pub verified: Icon, + pub unverified: Icon, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct Diff { + pub added: Icon, + pub deleted: Icon, + pub modified: Icon, +} + +fn icon_color_to_style<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let s: String = Deserialize::deserialize(deserializer)?; + let mut style = Style::default(); + if !s.is_empty() { + match hex_string_to_rgb(&s) { + Ok(c) => { + style = style.fg(c); + } + Err(e) => { + log::error!("{}", e); + } + }; + Ok(Some(IconStyle::Custom(style))) + } else { + Ok(None) + } +} + +pub fn hex_string_to_rgb(s: &str) -> Result { + if s.starts_with('#') && s.len() >= 7 { + if let (Ok(red), Ok(green), Ok(blue)) = ( + u8::from_str_radix(&s[1..3], 16), + u8::from_str_radix(&s[3..5], 16), + u8::from_str_radix(&s[5..7], 16), + ) { + return Ok(Color::Rgb(red, green, blue)); + } + } + Err(format!("Icon color: malformed hexcode: {}", s)) +} + +pub struct Loader { + /// Icons directories to search from highest to lowest priority + icons_dirs: Vec, +} + +pub static DEFAULT_ICONS_DATA: Lazy = Lazy::new(|| { + let bytes = include_bytes!("../../icons.toml"); + toml::from_str(str::from_utf8(bytes).unwrap()).expect("Failed to parse base 16 default theme") +}); + +pub static DEFAULT_ICONS: Lazy = Lazy::new(|| Icons { + name: "default".into(), + ..Icons::from(DEFAULT_ICONS_DATA.clone()) +}); + +impl Loader { + /// Creates a new loader that can load icons flavors from two directories. + pub fn new>(dirs: &[PathBuf]) -> Self { + Self { + icons_dirs: dirs.iter().map(|p| p.join("icons")).collect(), + } + } + + /// Loads icons flavors first looking in the `user_dir` then in `default_dir`. + /// The `theme` is needed in order to load default styles for diagnostic icons. + pub fn load( + &self, + name: &str, + theme: &Theme, + true_color: bool, + ) -> Result { + if name == "default" { + return Ok(self.default(theme)); + } + + let mut visited_paths = HashSet::new(); + let default_icons = HashMap::from([("default", &DEFAULT_ICONS_DATA)]); + let mut icons = helix_loader::load_inheritable_toml( + name, + &self.icons_dirs, + &mut visited_paths, + &default_icons, + Self::merge_icons, + ) + .map(Icons::from)?; + + // Remove all styles when there is no truecolor support. + // Not classy, but less cumbersome than trying to pass a parameter to a deserializer. + if !true_color { + icons.reset_styles(); + } else { + icons.set_diagnostic_icons_base_style(theme); + icons.set_symbolkind_icons_base_style(theme); + } + + Ok(Icons { + name: name.into(), + ..icons + }) + } + + fn merge_icons(parent: Value, child: Value) -> Value { + merge_toml_values(parent, child, 3) + } + + /// Returns the default icon flavor. + /// The `theme` is needed in order to load default styles for diagnostic icons. + pub fn default(&self, theme: &Theme) -> Icons { + let mut icons = DEFAULT_ICONS.clone(); + icons.set_diagnostic_icons_base_style(theme); + icons.set_symbolkind_icons_base_style(theme); + icons + } +} + +impl From for Icons { + fn from(value: Value) -> Self { + if let Value::Table(mut table) = value { + // remove inherits from value to prevent errors + table.remove("inherits"); + let toml_str = table.to_string(); + match toml::from_str(&toml_str) { + Ok(icons) => icons, + Err(e) => { + log::error!("Failed to load icons, falling back to default: {}\n", e); + DEFAULT_ICONS.clone() + } + } + } else { + warn!("Expected icons TOML value to be a table, found {:?}", value); + DEFAULT_ICONS.clone() + } + } +} diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index c3f67345b361..60c6efd5589a 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -12,6 +12,7 @@ pub mod handlers { pub mod lsp; } pub mod base64; +pub mod icons; pub mod info; pub mod input; pub mod keyboard; diff --git a/icons.toml b/icons.toml new file mode 100644 index 000000000000..9c3bc8db7dca --- /dev/null +++ b/icons.toml @@ -0,0 +1,18 @@ +name = "default" + +# All icons here must be available as [default Unicode characters](https://en.wikipedia.org/wiki/List_of_Unicode_characters) + +[diagnostic] +error = {icon = "●"} +warning = {icon = "●"} +info = {icon = "●"} +hint = {icon = "●"} + +[breakpoint] +verified = {icon = "▲"} +unverified = {icon = "⊚"} + +[diff] +added = {icon = "▍"} +deleted = {icon = "▔"} +modified = {icon = "▍"} diff --git a/runtime/icons/nerdfonts.toml b/runtime/icons/nerdfonts.toml new file mode 100644 index 000000000000..82385e6bf687 --- /dev/null +++ b/runtime/icons/nerdfonts.toml @@ -0,0 +1,284 @@ +name = "nerdfonts" + +[diagnostic] +error = {icon = ""} +warning = {icon = ""} +info = {icon = ""} +hint = {icon = ""} + +[breakpoint] +verified = {icon = "▲"} +unverified = {icon = "⊚"} + +[diff] +added = {icon = "▍"} +deleted = {icon = "▔"} +modified = {icon = "▍"} + +[symbol-kind] +file = {icon = ""} +module = {icon = ""} +namespace = {icon = ""} +package = {icon = ""} +class = {icon = ""} +method = {icon = ""} +property = {icon = ""} +field = {icon = ""} +constructor = {icon = ""} +enumeration = {icon = ""} +interface = {icon = ""} +variable = {icon = ""} +function = {icon = "󰊕"} +constant = {icon = ""} +string = {icon = ""} +number = {icon = ""} +boolean = {icon = ""} +array = {icon = ""} +object = {icon = ""} +key = {icon = ""} +null = {icon = "󰟢"} +enum-member = {icon = ""} +structure = {icon = ""} +event = {icon = ""} +operator = {icon = ""} +type-parameter = {icon = ""} + +[ui] +file = {icon = ""} +folder = {icon = ""} +folder_opened = {icon = ""} +git_branch = {icon = ""} + +[mime-type] +# This is heavily based on https://github.com/nvim-tree/nvim-web-devicons +".babelrc" = { icon = "ﬥ", color = "#cbcb41" } +".bash_profile" = { icon = "", color = "#89e051" } +".bashrc" = { icon = "", color = "#89e051" } +".DS_Store" = { icon = "", color = "#41535b" } +".gitattributes" = { icon = "", color = "#41535b" } +".gitconfig" = { icon = "", color = "#41535b" } +".gitignore" = { icon = "", color = "#41535b" } +".gitlab-ci.yml" = { icon = "", color = "#e24329" } +".gitmodules" = { icon = "", color = "#41535b" } +".gvimrc" = { icon = "", color = "#019833" } +".npmignore" = { icon = "", color = "#E8274B" } +".npmrc" = { icon = "", color = "#E8274B" } +".settings.json" = { icon = "", color = "#854CC7" } +".vimrc" = { icon = "", color = "#019833" } +".zprofile" = { icon = "", color = "#89e051" } +".zshenv" = { icon = "", color = "#89e051" } +".zshrc" = { icon = "", color = "#89e051" } +"Brewfile" = { icon = "", color = "#701516" } +"CMakeLists.txt" = { icon = "", color = "#6d8086" } +"COMMIT_EDITMSG" = { icon = "", color = "#41535b" } +"COPYING" = { icon = "", color = "#cbcb41" } +"COPYING.LESSER" = { icon = "", color = "#cbcb41" } +"Dockerfile" = { icon = "", color = "#384d54" } +"Gemfile$" = { icon = "", color = "#701516" } +"LICENSE" = { icon = "", color = "#d0bf41" } +"R" = { icon = "ﳒ", color = "#358a5b" } +"Rmd" = { icon = "", color = "#519aba" } +"Vagrantfile$" = { icon = "", color = "#1563FF" } +"_gvimrc" = { icon = "", color = "#019833" } +"_vimrc" = { icon = "", color = "#019833" } +"ai" = { icon = "", color = "#cbcb41" } +"awk" = { icon = "", color = "#4d5a5e" } +"bash" = { icon = "", color = "#89e051" } +"bat" = { icon = "", color = "#C1F12E" } +"bmp" = { icon = "", color = "#a074c4" } +"c" = { icon = "", color = "#599eff" } +"c++" = { icon = "", color = "#f34b7d" } +"cbl" = { icon = "⚙", color = "#005ca5" } +"cc" = { icon = "", color = "#f34b7d" } +"cfg" = { icon = "", color = "#ECECEC" } +"clj" = { icon = "", color = "#8dc149" } +"cljc" = { icon = "", color = "#8dc149" } +"cljs" = { icon = "", color = "#519aba" } +"cljd" = { icon = "", color = "#519aba" } +"cmake" = { icon = "", color = "#6d8086" } +"cob" = { icon = "⚙", color = "#005ca5" } +"cobol" = { icon = "⚙", color = "#005ca5" } +"coffee" = { icon = "", color = "#cbcb41" } +"conf" = { icon = "", color = "#6d8086" } +"config.ru" = { icon = "", color = "#701516" } +"cp" = { icon = "", color = "#519aba" } +"cpp" = { icon = "", color = "#519aba" } +"cpy" = { icon = "⚙", color = "#005ca5" } +"cr" = { icon = "" } +"cs" = { icon = "", color = "#596706" } +"csh" = { icon = "", color = "#4d5a5e" } +"cson" = { icon = "", color = "#cbcb41" } +"css" = { icon = "", color = "#42a5f5" } +"csv" = { icon = "", color = "#89e051" } +"cxx" = { icon = "", color = "#519aba" } +"d" = { icon = "", color = "#427819" } +"dart" = { icon = "", color = "#03589C" } +"db" = { icon = "", color = "#dad8d8" } +"desktop" = { icon = "", color = "#563d7c" } +"diff" = { icon = "", color = "#41535b" } +"doc" = { icon = "", color = "#185abd" } +"dockerfile" = { icon = "", color = "#384d54" } +"drl" = { icon = "", color = "#ffafaf" } +"dropbox" = { icon = "", color = "#0061FE" } +"dump" = { icon = "", color = "#dad8d8" } +"edn" = { icon = "", color = "#519aba" } +"eex" = { icon = "", color = "#a074c4" } +"ejs" = { icon = "", color = "#cbcb41" } +"elm" = { icon = "", color = "#519aba" } +"epp" = { icon = "", color = "#FFA61A" } +"erb" = { icon = "", color = "#701516" } +"erl" = { icon = "", color = "#B83998" } +"ex" = { icon = "", color = "#a074c4" } +"exs" = { icon = "", color = "#a074c4" } +"f#" = { icon = "", color = "#519aba" } +"favicon.ico" = { icon = "", color = "#cbcb41" } +"fnl" = { icon = "🌜", color = "#fff3d7" } +"fish" = { icon = "", color = "#4d5a5e" } +"fs" = { icon = "", color = "#519aba" } +"fsi" = { icon = "", color = "#519aba" } +"fsscript" = { icon = "", color = "#519aba" } +"fsx" = { icon = "", color = "#519aba" } +"gd" = { icon = "", color = "#6d8086" } +"gemspec" = { icon = "", color = "#701516" } +"gif" = { icon = "", color = "#a074c4" } +"git" = { icon = "", color = "#F14C28" } +"glb" = { icon = "", color = "#FFB13B" } +"go" = { icon = "", color = "#519aba" } +"godot" = { icon = "", color = "#6d8086" } +"graphql" = { icon = "", color = "#e535ab" } +"gruntfile" = { icon = "", color = "#e37933" } +"gulpfile" = { icon = "", color = "#cc3e44" } +"h" = { icon = "", color = "#a074c4" } +"haml" = { icon = "", color = "#eaeae1" } +"hbs" = { icon = "", color = "#f0772b" } +"heex" = { icon = "", color = "#a074c4" } +"hh" = { icon = "", color = "#a074c4" } +"hpp" = { icon = "", color = "#a074c4" } +"hrl" = { icon = "", color = "#B83998" } +"hs" = { icon = "", color = "#a074c4" } +"htm" = { icon = "", color = "#e34c26" } +"html" = { icon = "", color = "#e44d26" } +"hxx" = { icon = "", color = "#a074c4" } +"ico" = { icon = "", color = "#cbcb41" } +"import" = { icon = "", color = "#ECECEC" } +"ini" = { icon = "", color = "#6d8086" } +"java" = { icon = "", color = "#cc3e44" } +"jl" = { icon = "", color = "#a270ba" } +"jpeg" = { icon = "", color = "#a074c4" } +"jpg" = { icon = "", color = "#a074c4" } +"js" = { icon = "", color = "#cbcb41" } +"json" = { icon = "", color = "#cbcb41" } +"json5" = { icon = "ﬥ", color = "#cbcb41" } +"jsx" = { icon = "", color = "#519aba" } +"ksh" = { icon = "", color = "#4d5a5e" } +"kt" = { icon = "", color = "#F88A02" } +"kts" = { icon = "", color = "#F88A02" } +"leex" = { icon = "", color = "#a074c4" } +"less" = { icon = "", color = "#563d7c" } +"lhs" = { icon = "", color = "#a074c4" } +"license" = { icon = "", color = "#cbcb41" } +"lua" = { icon = "", color = "#51a0cf" } +"luau" = { icon = "", color = "#51a0cf" } +"makefile" = { icon = "", color = "#6d8086" } +"markdown" = { icon = "", color = "#d74c4c" } +"material" = { icon = "", color = "#B83998" } +"md" = { icon = "", color = "#d74c4c" } +"mdx" = { icon = "", color = "#d74c4c" } +"mint" = { icon = "", color = "#87c095" } +"mix.lock" = { icon = "", color = "#a074c4" } +"mjs" = { icon = "", color = "#f1e05a" } +"ml" = { icon = "λ", color = "#e37933" } +"mli" = { icon = "λ", color = "#e37933" } +"mo" = { icon = "∞", color = "#9772FB" } +"mustache" = { icon = "", color = "#e37933" } +"nim" = { icon = "👑", color = "#f3d400" } +"nix" = { icon = "", color = "#7ebae4" } +"node_modules" = { icon = "", color = "#E8274B" } +"opus" = { icon = "", color = "#F88A02" } +"otf" = { icon = "", color = "#ECECEC" } +"package.json" = { icon = "", color = "#e8274b" } +"package-lock.json" = { icon = "", color = "#7a0d21" } +"pck" = { icon = "", color = "#6d8086" } +"pdf" = { icon = "", color = "#b30b00" } +"php" = { icon = "", color = "#a074c4" } +"pl" = { icon = "", color = "#519aba" } +"pm" = { icon = "", color = "#519aba" } +"png" = { icon = "", color = "#a074c4" } +"pp" = { icon = "", color = "#FFA61A" } +"ppt" = { icon = "", color = "#cb4a32" } +"pro" = { icon = "", color = "#e4b854" } +"Procfile" = { icon = "", color = "#a074c4" } +"ps1" = { icon = "", color = "#4d5a5e" } +"psb" = { icon = "", color = "#519aba" } +"psd" = { icon = "", color = "#519aba" } +"py" = { icon = "", color = "#ffbc03" } +"pyc" = { icon = "", color = "#ffe291" } +"pyd" = { icon = "", color = "#ffe291" } +"pyo" = { icon = "", color = "#ffe291" } +"query" = { icon = "", color = "#90a850" } +"r" = { icon = "ﳒ", color = "#358a5b" } +"rake" = { icon = "", color = "#701516" } +"rakefile" = { icon = "", color = "#701516" } +"rb" = { icon = "", color = "#701516" } +"rlib" = { icon = "", color = "#dea584" } +"rmd" = { icon = "", color = "#519aba" } +"rproj" = { icon = "鉶", color = "#358a5b" } +"rs" = { icon = "", color = "#dea584" } +"rss" = { icon = "", color = "#FB9D3B" } +"sass" = { icon = "", color = "#f55385" } +"sbt" = { icon = "", color = "#cc3e44" } +"scala" = { icon = "", color = "#cc3e44" } +"scm" = { icon = "ﬦ" } +"scss" = { icon = "", color = "#f55385" } +"sh" = { icon = "", color = "#4d5a5e" } +"sig" = { icon = "λ", color = "#e37933" } +"slim" = { icon = "", color = "#e34c26" } +"sln" = { icon = "", color = "#854CC7" } +"sml" = { icon = "λ", color = "#e37933" } +"sql" = { icon = "", color = "#dad8d8" } +"sqlite" = { icon = "", color = "#dad8d8" } +"sqlite3" = { icon = "", color = "#dad8d8" } +"styl" = { icon = "", color = "#8dc149" } +"sublime" = { icon = "", color = "#e37933" } +"suo" = { icon = "", color = "#854CC7" } +"sv" = { icon = "", color = "#019833" } +"svelte" = { icon = "", color = "#ff3e00" } +"svh" = { icon = "", color = "#019833" } +"svg" = { icon = "ﰟ", color = "#FFB13B" } +"swift" = { icon = "", color = "#e37933" } +"t" = { icon = "", color = "#519aba" } +"tbc" = { icon = "﯑", color = "#1e5cb3" } +"tcl" = { icon = "﯑", color = "#1e5cb3" } +"terminal" = { icon = "", color = "#31B53E" } +"tex" = { icon = "ﭨ", color = "#3D6117" } +"tf" = { icon = "", color = "#5F43E9" } +"tfvars" = { icon = "", color = "#5F43E9" } +"toml" = { icon = "", color = "#6d8086" } +"tres" = { icon = "", color = "#cbcb41" } +"ts" = { icon = "", color = "#519aba" } +"tscn" = { icon = "", color = "#a074c4" } +"tsx" = { icon = "", color = "#519aba" } +"twig" = { icon = "", color = "#8dc149" } +"txt" = { icon = "", color = "#89e051" } +"v" = { icon = "", color = "#019833" } +"vh" = { icon = "", color = "#019833" } +"vhd" = { icon = "", color = "#019833" } +"vhdl" = { icon = "", color = "#019833" } +"vim" = { icon = "", color = "#019833" } +"vue" = { icon = "﵂", color = "#8dc149" } +"webmanifest" = { icon = "", color = "#f1e05a" } +"webp" = { icon = "", color = "#a074c4" } +"webpack" = { icon = "ﰩ", color = "#519aba" } +"xcplayground" = { icon = "", color = "#e37933" } +"xls" = { icon = "", color = "#207245" } +"xml" = { icon = "謹", color = "#e37933" } +"xul" = { icon = "", color = "#e37933" } +"yaml" = { icon = "", color = "#6d8086" } +"yml" = { icon = "", color = "#6d8086" } +"zig" = { icon = "", color = "#f69a1b" } +"zsh" = { icon = "", color = "#89e051" } +"sol" = { icon = "ﲹ", color = "#519aba" } +".env" = { icon = "", color = "#faf743" } +"prisma" = { icon = "卑" } +"lock" = { icon = "", color = "#bbbbbb" } +"log" = { icon = "" } From 2b2aee1344efee605aa43536697c8de7e465b3a4 Mon Sep 17 00:00:00 2001 From: LazyTanuki <43273245+lazytanuki@users.noreply.github.com> Date: Fri, 10 Mar 2023 18:06:03 +0100 Subject: [PATCH 5/8] feat: add icons launch and runtime loading --- helix-term/src/application.rs | 46 +++++++++++++++++++++++++++----- helix-term/src/commands/typed.rs | 31 +++++++++++++++++++++ helix-term/src/config.rs | 5 ++++ helix-term/src/ui/mod.rs | 31 +++++++++++++++++++++ helix-view/src/editor.rs | 40 +++++++++++++++++++++++++++ helix-view/src/icons.rs | 7 ++--- 6 files changed, 149 insertions(+), 11 deletions(-) diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 7e9684827a9c..9eece52b4a9d 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -11,7 +11,7 @@ use helix_view::{ document::DocumentSavedEventResult, editor::{ConfigEvent, EditorEvent}, graphics::Rect, - theme, + icons, theme, tree::Layout, Align, Editor, }; @@ -70,6 +70,7 @@ pub struct Application { #[allow(dead_code)] theme_loader: Arc, + icons_loader: Arc, #[allow(dead_code)] syn_loader: Arc, @@ -112,9 +113,9 @@ impl Application { use helix_view::editor::Action; - let mut theme_parent_dirs = vec![helix_loader::config_dir()]; - theme_parent_dirs.extend(helix_loader::runtime_dirs().iter().cloned()); - let theme_loader = std::sync::Arc::new(theme::Loader::new(&theme_parent_dirs)); + let mut theme_and_icons_parent_dirs = vec![helix_loader::config_dir()]; + theme_and_icons_parent_dirs.extend(helix_loader::runtime_dirs().iter().cloned()); + let theme_loader = std::sync::Arc::new(theme::Loader::new(&theme_and_icons_parent_dirs)); let true_color = config.editor.true_color || crate::true_color(); let theme = config @@ -132,6 +133,21 @@ impl Application { }) .unwrap_or_else(|| theme_loader.default_theme(true_color)); + let icons_loader = std::sync::Arc::new(icons::Loader::new(&theme_and_icons_parent_dirs)); + let icons = config + .icons + .as_ref() + .and_then(|icons| { + icons_loader + .load(icons, &theme, true_color) + .map_err(|e| { + log::warn!("failed to load icons `{}` - {}", icons, e); + e + }) + .ok() + }) + .unwrap_or_else(|| icons_loader.default(&theme)); + let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf)); #[cfg(not(feature = "integration"))] @@ -147,12 +163,16 @@ impl Application { let mut editor = Editor::new( area, theme_loader.clone(), + icons_loader.clone(), syn_loader.clone(), Arc::new(Map::new(Arc::clone(&config), |config: &Config| { &config.editor })), ); + editor.set_theme(theme); + editor.set_icons(icons); + let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| { &config.keys })); @@ -226,8 +246,6 @@ impl Application { .unwrap_or_else(|_| editor.new_file(Action::VerticalSplit)); } - editor.set_theme(theme); - #[cfg(windows)] let signals = futures_util::stream::empty(); #[cfg(not(windows))] @@ -248,6 +266,7 @@ impl Application { config, theme_loader, + icons_loader, syn_loader, signals, @@ -425,12 +444,27 @@ impl Application { Ok(()) } + /// Refresh icons after config change + fn refresh_icons(&mut self, config: &Config) -> Result<(), Error> { + if let Some(icons) = config.icons.clone() { + let true_color = config.editor.true_color || crate::true_color(); + let icons = self + .icons_loader + .load(&icons, &self.editor.theme, true_color) + .map_err(|err| anyhow::anyhow!("Failed to load icons `{}`: {}", icons, err))?; + self.editor.set_icons(icons); + } + + Ok(()) + } + fn refresh_config(&mut self) { let mut refresh_config = || -> Result<(), Error> { let default_config = Config::load_default() .map_err(|err| anyhow::anyhow!("Failed to load config: {}", err))?; self.refresh_language_config()?; self.refresh_theme(&default_config)?; + self.refresh_icons(&default_config)?; self.terminal .reconfigure(default_config.editor.clone().into())?; // Store new config diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 824abbf4c8f9..b87f374fe6f3 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -883,6 +883,30 @@ fn theme( Ok(()) } +fn icons( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + let true_color = cx.editor.config.load().true_color || crate::true_color(); + if let PromptEvent::Validate = event { + if let Some(flavor_name) = args.first() { + let icons = cx + .editor + .icons_loader + .load(flavor_name, &cx.editor.theme, true_color) + .map_err(|err| anyhow!("Could not load icon flavor: {}", err))?; + cx.editor.set_icons(icons); + } else { + let name = cx.editor.icons.name().to_string(); + + cx.editor.set_status(name); + } + }; + + Ok(()) +} + fn yank_main_selection_to_clipboard( cx: &mut compositor::Context, _args: &[Cow], @@ -2474,6 +2498,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ fun: theme, signature: CommandSignature::positional(&[completers::theme]), }, + TypableCommand { + name: "icons", + aliases: &[], + doc: "Change the editor icon flavor (show current flavor if no name specified).", + fun: icons, + signature: CommandSignature::positional(&[completers::icons]), + }, TypableCommand { name: "clipboard-yank", aliases: &[], diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index f37b03ec7b43..44824aa4a1b7 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -13,6 +13,7 @@ use toml::de::Error as TomlError; pub struct Config { pub theme: Option, pub keys: HashMap, + pub icons: Option, pub editor: helix_view::editor::Config, } @@ -21,6 +22,7 @@ pub struct Config { pub struct ConfigRaw { pub theme: Option, pub keys: Option>, + pub icons: Option, pub editor: Option, } @@ -28,6 +30,7 @@ impl Default for Config { fn default() -> Config { Config { theme: None, + icons: None, keys: keymap::default(), editor: helix_view::editor::Config::default(), } @@ -86,6 +89,7 @@ impl Config { Config { theme: local.theme.or(global.theme), + icons: local.icons.or(global.icons), keys, editor, } @@ -102,6 +106,7 @@ impl Config { } Config { theme: config.theme, + icons: config.icons, keys, editor: config.editor.map_or_else( || Ok(helix_view::editor::Config::default()), diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index a43f4fadc485..4fbcb9b09e53 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -311,6 +311,37 @@ pub mod completers { names } + pub fn icons(_editor: &Editor, input: &str) -> Vec { + let mut names = helix_loader::read_toml_names(&helix_loader::config_dir().join("icons")); + for rt_dir in helix_loader::runtime_dirs() { + names.extend(helix_loader::read_toml_names(&rt_dir.join("icons"))); + } + names.push("default".into()); + names.sort(); + names.dedup(); + + let mut names: Vec<_> = names + .into_iter() + .map(|name| ((0..), Cow::from(name))) + .collect(); + + let matcher = Matcher::default(); + + let mut matches: Vec<_> = names + .into_iter() + .filter_map(|(_range, name)| { + matcher.fuzzy_match(&name, input).map(|score| (name, score)) + }) + .collect(); + + matches.sort_unstable_by(|(name1, score1), (name2, score2)| { + (Reverse(*score1), name1).cmp(&(Reverse(*score2), name2)) + }); + names = matches.into_iter().map(|(name, _)| ((0..), name)).collect(); + + names + } + /// Recursive function to get all keys from this value and add them to vec fn get_keys(value: &serde_json::Value, vec: &mut Vec, scope: Option<&str>) { if let Some(map) = value.as_object() { diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 1f27603c9011..f54f34d63ce2 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -3,6 +3,7 @@ use crate::{ clipboard::{get_clipboard_provider, ClipboardProvider}, document::{DocumentSavedEventFuture, DocumentSavedEventResult, Mode, SavePoint}, graphics::{CursorKind, Rect}, + icons::{self, Icons}, info::Info, input::KeyEvent, theme::{self, Theme}, @@ -211,6 +212,27 @@ impl Default for FilePickerConfig { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] +pub struct IconsConfig { + /// Enables icons in front of buffer names in bufferline. Defaults to `true` + pub bufferline: bool, + /// Enables icons in front of items in the picker. Defaults to `true` + pub picker: bool, + /// Enables icons in front of items in the statusline. Defaults to `true` + pub statusline: bool, +} + +impl Default for IconsConfig { + fn default() -> Self { + Self { + bufferline: true, + picker: true, + statusline: true, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct Config { @@ -284,6 +306,8 @@ pub struct Config { pub soft_wrap: SoftWrap, /// Workspace specific lsp ceiling dirs pub workspace_lsp_roots: Vec, + /// Icons configuration + pub icons: IconsConfig, } #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -759,6 +783,7 @@ impl Default for Config { text_width: 80, completion_replace: false, workspace_lsp_roots: Vec::new(), + icons: IconsConfig::default(), } } } @@ -835,6 +860,8 @@ pub struct Editor { /// The currently applied editor theme. While previewing a theme, the previewed theme /// is set here. pub theme: Theme, + pub icons: Icons, + pub icons_loader: Arc, /// The primary Selection prior to starting a goto_line_number preview. This is /// restored when the preview is aborted, or added to the jumplist when it is @@ -938,12 +965,15 @@ impl Editor { pub fn new( mut area: Rect, theme_loader: Arc, + icons_loader: Arc, syn_loader: Arc, config: Arc>, ) -> Self { let language_servers = helix_lsp::Registry::new(syn_loader.clone()); let conf = config.load(); let auto_pairs = (&conf.auto_pairs).into(); + let theme = theme_loader.default(); + let icons = icons_loader.default(&theme); // HAXX: offset the render area height by 1 to account for prompt/commandline area.height -= 1; @@ -986,6 +1016,8 @@ impl Editor { needs_redraw: false, cursor_cache: Cell::new(None), completion_request_handle: None, + icons, + icons_loader, } } @@ -1086,6 +1118,9 @@ impl Editor { } ThemeAction::Set => { self.last_theme = None; + // Reload the icons to apply default colors based on theme + self.icons.set_diagnostic_icons_base_style(&theme); + self.icons.set_symbolkind_icons_base_style(&theme); self.theme = theme; } } @@ -1093,6 +1128,11 @@ impl Editor { self._refresh(); } + pub fn set_icons(&mut self, icons: Icons) { + self.icons = icons; + self._refresh(); + } + #[inline] pub fn language_server_by_id(&self, language_server_id: usize) -> Option<&helix_lsp::Client> { self.language_servers.get_by_id(language_server_id) diff --git a/helix-view/src/icons.rs b/helix-view/src/icons.rs index 3e1ebb4d35c7..5921d778bdcf 100644 --- a/helix-view/src/icons.rs +++ b/helix-view/src/icons.rs @@ -3,10 +3,7 @@ use log::warn; use once_cell::sync::Lazy; use serde::Deserialize; use std::collections::{HashMap, HashSet}; -use std::{ - path::{Path, PathBuf}, - str, -}; +use std::{path::PathBuf, str}; use toml::Value; use crate::graphics::{Color, Style}; @@ -220,7 +217,7 @@ pub static DEFAULT_ICONS: Lazy = Lazy::new(|| Icons { impl Loader { /// Creates a new loader that can load icons flavors from two directories. - pub fn new>(dirs: &[PathBuf]) -> Self { + pub fn new(dirs: &[PathBuf]) -> Self { Self { icons_dirs: dirs.iter().map(|p| p.join("icons")).collect(), } From 7abaed47c845de7075c472774bc4e673f00e1447 Mon Sep 17 00:00:00 2001 From: LazyTanuki <43273245+lazytanuki@users.noreply.github.com> Date: Fri, 10 Mar 2023 18:37:22 +0100 Subject: [PATCH 6/8] feat: add icons to pickers --- helix-term/src/application.rs | 2 +- helix-term/src/commands.rs | 57 ++++++++++++---- helix-term/src/commands/dap.rs | 11 +-- helix-term/src/commands/lsp.rs | 112 ++++++++++++++++++++++++------- helix-term/src/commands/typed.rs | 9 +-- helix-term/src/ui/completion.rs | 4 +- helix-term/src/ui/menu.rs | 28 ++++---- helix-term/src/ui/mod.rs | 8 ++- helix-term/src/ui/picker.rs | 28 +++++--- helix-tui/src/text.rs | 10 +++ 10 files changed, 202 insertions(+), 67 deletions(-) diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 9eece52b4a9d..c83f11b6a68e 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -189,7 +189,7 @@ impl Application { if first.is_dir() { std::env::set_current_dir(first).context("set current dir")?; editor.new_file(Action::VerticalSplit); - let picker = ui::file_picker(".".into(), &config.load().editor); + let picker = ui::file_picker(".".into(), &config.load().editor, &editor.icons); compositor.push(Box::new(overlaid(picker))); } else { let nr_of_files = args.files.len(); diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 1e89fe1cb69b..b807f4ca8e23 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -6,7 +6,7 @@ pub use dap::*; use helix_vcs::Hunk; pub use lsp::*; use tokio::sync::oneshot; -use tui::widgets::Row; +use tui::{text::Span, widgets::Row}; pub use typed::*; use helix_core::{ @@ -35,6 +35,7 @@ use helix_view::{ clipboard::ClipboardType, document::{FormatterError, Mode, SCRATCH_BUFFER_NAME}, editor::{Action, CompleteAction, Motion}, + icons::Icons, info::Info, input::KeyEvent, keyboard::KeyCode, @@ -2019,11 +2020,12 @@ fn global_search(cx: &mut Context) { impl ui::menu::Item for FileResult { type Data = Option; - fn format(&self, current_path: &Self::Data) -> Row { + fn format<'a>(&self, current_path: &Self::Data, icons: Option<&'a Icons>) -> Row { + let icon = icons.and_then(|icons| icons.icon_from_path(Some(&self.path))); let relative_path = helix_core::path::get_relative_path(&self.path) .to_string_lossy() .into_owned(); - if current_path + let path_span: Span = if current_path .as_ref() .map(|p| p == &self.path) .unwrap_or(false) @@ -2031,6 +2033,12 @@ fn global_search(cx: &mut Context) { format!("{} (*)", relative_path).into() } else { relative_path.into() + }; + + if let Some(icon) = icon { + Row::new([icon.into(), path_span]) + } else { + path_span.into() } } } @@ -2147,6 +2155,7 @@ fn global_search(cx: &mut Context) { let picker = FilePicker::new( all_matches, current_path, + editor.config().icons.picker.then_some(&editor.icons), move |cx, FileResult { path, line_num }, action| { match cx.editor.open(path, action) { Ok(_) => {} @@ -2487,7 +2496,7 @@ fn append_mode(cx: &mut Context) { fn file_picker(cx: &mut Context) { let root = find_workspace().0; - let picker = ui::file_picker(root, &cx.editor.config()); + let picker = ui::file_picker(root, &cx.editor.config(), &cx.editor.icons); cx.push_layer(Box::new(overlaid(picker))); } @@ -2504,12 +2513,12 @@ fn file_picker_in_current_buffer_directory(cx: &mut Context) { } }; - let picker = ui::file_picker(path, &cx.editor.config()); + let picker = ui::file_picker(path, &cx.editor.config(), &cx.editor.icons); cx.push_layer(Box::new(overlaid(picker))); } fn file_picker_in_current_directory(cx: &mut Context) { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("./")); - let picker = ui::file_picker(cwd, &cx.editor.config()); + let picker = ui::file_picker(cwd, &cx.editor.config(), &cx.editor.icons); cx.push_layer(Box::new(overlaid(picker))); } @@ -2527,7 +2536,7 @@ fn buffer_picker(cx: &mut Context) { impl ui::menu::Item for BufferMeta { type Data = (); - fn format(&self, _data: &Self::Data) -> Row { + fn format<'a>(&self, _data: &Self::Data, icons: Option<&'a Icons>) -> Row { let path = self .path .as_deref() @@ -2537,6 +2546,9 @@ fn buffer_picker(cx: &mut Context) { None => SCRATCH_BUFFER_NAME, }; + // Get the filetype icon, or a "file" icon for scratch buffers + let icon = icons.and_then(|icons| icons.icon_from_path(self.path.as_ref())); + let mut flags = String::new(); if self.is_modified { flags.push('+'); @@ -2545,7 +2557,17 @@ fn buffer_picker(cx: &mut Context) { flags.push('*'); } - Row::new([self.id.to_string(), flags, path.to_string()]) + if let Some(icon) = icon { + let icon_span = Span::from(icon); + Row::new(vec![ + icon_span, + self.id.to_string().into(), + flags.into(), + path.to_string().into(), + ]) + } else { + Row::new([self.id.to_string(), flags, path.to_string()]) + } } } @@ -2570,6 +2592,7 @@ fn buffer_picker(cx: &mut Context) { let picker = FilePicker::new( items, (), + cx.editor.config().icons.picker.then_some(&cx.editor.icons), |cx, meta, action| { cx.editor.switch(meta.id, action); }, @@ -2598,7 +2621,10 @@ fn jumplist_picker(cx: &mut Context) { impl ui::menu::Item for JumpMeta { type Data = (); - fn format(&self, _data: &Self::Data) -> Row { + fn format<'a>(&self, _data: &Self::Data, icons: Option<&'a Icons>) -> Row { + // Get the filetype icon, or a "file" icon for scratch buffers + let icon = icons.and_then(|icons| icons.icon_from_path(self.path.as_ref())); + let path = self .path .as_deref() @@ -2618,7 +2644,13 @@ fn jumplist_picker(cx: &mut Context) { } else { format!(" ({})", flags.join("")) }; - format!("{} {}{} {}", self.id, path, flag, self.text).into() + + let path_span: Span = format!("{} {}{} {}", self.id, path, flag, self.text).into(); + if let Some(icon) = icon { + Row::new(vec![icon.into(), path_span]) + } else { + path_span.into() + } } } @@ -2659,6 +2691,7 @@ fn jumplist_picker(cx: &mut Context) { }) .collect(), (), + cx.editor.config().icons.picker.then_some(&cx.editor.icons), |cx, meta, action| { cx.editor.switch(meta.id, action); let config = cx.editor.config(); @@ -2678,7 +2711,7 @@ fn jumplist_picker(cx: &mut Context) { impl ui::menu::Item for MappableCommand { type Data = ReverseKeymap; - fn format(&self, keymap: &Self::Data) -> Row { + fn format<'a>(&self, keymap: &Self::Data, _icons: Option<&'a Icons>) -> Row { let fmt_binding = |bindings: &Vec>| -> String { bindings.iter().fold(String::new(), |mut acc, bind| { if !acc.is_empty() { @@ -2720,7 +2753,7 @@ pub fn command_palette(cx: &mut Context) { } })); - let picker = Picker::new(commands, keymap, move |cx, command, _action| { + let picker = Picker::new(commands, keymap, None, move |cx, command, _action| { let mut ctx = Context { register: None, count: std::num::NonZeroUsize::new(1), diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index 84794bedfce9..ddd9f4f8848a 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -8,7 +8,7 @@ use dap::{StackFrame, Thread, ThreadStates}; use helix_core::syntax::{DebugArgumentValue, DebugConfigCompletion, DebugTemplate}; use helix_dap::{self as dap, Client}; use helix_lsp::block_on; -use helix_view::editor::Breakpoint; +use helix_view::{editor::Breakpoint, icons::Icons}; use serde_json::{to_value, Value}; use tokio_stream::wrappers::UnboundedReceiverStream; @@ -25,7 +25,7 @@ use helix_view::handlers::dap::{breakpoints_changed, jump_to_stack_frame, select impl ui::menu::Item for StackFrame { type Data = (); - fn format(&self, _data: &Self::Data) -> Row { + fn format<'a>(&self, _data: &Self::Data, _icons: Option<&'a Icons>) -> Row { self.name.as_str().into() // TODO: include thread_states in the label } } @@ -33,7 +33,7 @@ impl ui::menu::Item for StackFrame { impl ui::menu::Item for DebugTemplate { type Data = (); - fn format(&self, _data: &Self::Data) -> Row { + fn format<'a>(&self, _data: &Self::Data, _icons: Option<&'a Icons>) -> Row { self.name.as_str().into() } } @@ -41,7 +41,7 @@ impl ui::menu::Item for DebugTemplate { impl ui::menu::Item for Thread { type Data = ThreadStates; - fn format(&self, thread_states: &Self::Data) -> Row { + fn format<'a>(&self, thread_states: &Self::Data, _icons: Option<&'a Icons>) -> Row { format!( "{} ({})", self.name, @@ -76,6 +76,7 @@ fn thread_picker( let picker = FilePicker::new( threads, thread_states, + None, move |cx, thread, _action| callback_fn(cx.editor, thread), move |editor, thread| { let frames = editor.debugger.as_ref()?.stack_frames.get(&thread.id)?; @@ -273,6 +274,7 @@ pub fn dap_launch(cx: &mut Context) { cx.push_layer(Box::new(overlaid(Picker::new( templates, (), + None, |cx, template, _action| { let completions = template.completion.clone(); let name = template.name.clone(); @@ -731,6 +733,7 @@ pub fn dap_switch_stack_frame(cx: &mut Context) { let picker = FilePicker::new( frames, (), + None, move |cx, frame, _action| { let debugger = debugger!(cx.editor); // TODO: this should be simpler to find diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 3596df45bd7c..bd1caaa24757 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -3,17 +3,14 @@ use helix_lsp::{ block_on, lsp::{ self, CodeAction, CodeActionOrCommand, CodeActionTriggerKind, DiagnosticSeverity, - NumberOrString, + NumberOrString, SymbolKind, }, util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range}, Client, OffsetEncoding, }; use serde_json::Value; use tokio_stream::StreamExt; -use tui::{ - text::{Span, Spans}, - widgets::Row, -}; +use tui::{text::Span, widgets::Row}; use super::{align_view, push_jump, Align, Context, Editor, Open}; @@ -23,6 +20,7 @@ use helix_core::{ use helix_view::{ document::{DocumentInlayHints, DocumentInlayHintsId, Mode}, editor::Action, + icons::{self, Icon, Icons}, theme::Style, Document, View, }; @@ -71,7 +69,7 @@ impl ui::menu::Item for lsp::Location { /// Current working directory. type Data = PathBuf; - fn format(&self, cwdir: &Self::Data) -> Row { + fn format<'a>(&self, cwdir: &Self::Data, _icons: Option<&'a Icons>) -> Row { // The preallocation here will overallocate a few characters since it will account for the // URL's scheme, which is not used most of the time since that scheme will be "file://". // Those extra chars will be used to avoid allocating when writing the line number (in the @@ -110,11 +108,48 @@ impl ui::menu::Item for SymbolInformationItem { /// Path to currently focussed document type Data = Option; - fn format(&self, current_doc_path: &Self::Data) -> Row { + fn format<'a>(&self, current_doc_path: &Self::Data, icons: Option<&'a Icons>) -> Row { + let icon = + icons + .and_then(|icons| icons.symbol_kind.as_ref()) + .and_then(|symbol_kind_icons| match self.symbol.kind { + SymbolKind::FILE => symbol_kind_icons.get("file"), + SymbolKind::MODULE => symbol_kind_icons.get("module"), + SymbolKind::NAMESPACE => symbol_kind_icons.get("namespace"), + SymbolKind::PACKAGE => symbol_kind_icons.get("package"), + SymbolKind::CLASS => symbol_kind_icons.get("class"), + SymbolKind::METHOD => symbol_kind_icons.get("method"), + SymbolKind::PROPERTY => symbol_kind_icons.get("property"), + SymbolKind::FIELD => symbol_kind_icons.get("field"), + SymbolKind::CONSTRUCTOR => symbol_kind_icons.get("constructor"), + SymbolKind::ENUM => symbol_kind_icons.get("enumeration"), + SymbolKind::INTERFACE => symbol_kind_icons.get("interface"), + SymbolKind::FUNCTION => symbol_kind_icons.get("function"), + SymbolKind::VARIABLE => symbol_kind_icons.get("variable"), + SymbolKind::CONSTANT => symbol_kind_icons.get("constant"), + SymbolKind::STRING => symbol_kind_icons.get("string"), + SymbolKind::NUMBER => symbol_kind_icons.get("number"), + SymbolKind::BOOLEAN => symbol_kind_icons.get("boolean"), + SymbolKind::ARRAY => symbol_kind_icons.get("array"), + SymbolKind::OBJECT => symbol_kind_icons.get("object"), + SymbolKind::KEY => symbol_kind_icons.get("key"), + SymbolKind::NULL => symbol_kind_icons.get("null"), + SymbolKind::ENUM_MEMBER => symbol_kind_icons.get("enum-member"), + SymbolKind::STRUCT => symbol_kind_icons.get("structure"), + SymbolKind::EVENT => symbol_kind_icons.get("event"), + SymbolKind::OPERATOR => symbol_kind_icons.get("operator"), + SymbolKind::TYPE_PARAMETER => symbol_kind_icons.get("type-parameter"), + _ => Some(&icons::BLANK_ICON), + }); + if current_doc_path.as_ref() == Some(&self.symbol.location.uri) { - self.symbol.name.as_str().into() + if let Some(icon) = icon { + Row::new([Span::from(icon), self.symbol.name.as_str().into()]) + } else { + self.symbol.name.as_str().into() + } } else { - match self.symbol.location.uri.to_file_path() { + let symbol_span: Span = match self.symbol.location.uri.to_file_path() { Ok(path) => { let get_relative_path = path::get_relative_path(path.as_path()); format!( @@ -125,6 +160,11 @@ impl ui::menu::Item for SymbolInformationItem { .into() } Err(_) => format!("{} ({})", &self.symbol.name, &self.symbol.location.uri).into(), + }; + if let Some(icon) = icon { + Row::new([Span::from(icon), symbol_span]) + } else { + Row::from(symbol_span) } } } @@ -146,7 +186,18 @@ struct PickerDiagnostic { impl ui::menu::Item for PickerDiagnostic { type Data = (DiagnosticStyles, DiagnosticsFormat); - fn format(&self, (styles, format): &Self::Data) -> Row { + fn format<'a>(&self, (styles, format): &Self::Data, icons: Option<&'a Icons>) -> Row { + let icon: Option<&'a Icon> = + icons + .zip(self.diag.severity) + .map(|(icons, severity)| match severity { + DiagnosticSeverity::ERROR => &icons.diagnostic.error, + DiagnosticSeverity::WARNING => &icons.diagnostic.warning, + DiagnosticSeverity::HINT => &icons.diagnostic.hint, + DiagnosticSeverity::INFORMATION => &icons.diagnostic.info, + _ => &icons::BLANK_ICON, + }); + let mut style = self .diag .severity @@ -177,12 +228,20 @@ impl ui::menu::Item for PickerDiagnostic { } }; - Spans::from(vec![ - Span::raw(path), - Span::styled(&self.diag.message, style), - Span::styled(code, style), - ]) - .into() + if let Some(icon) = icon { + Row::new(vec![ + icon.into(), + Span::raw(path), + Span::styled(&self.diag.message, style), + Span::styled(code, style), + ]) + } else { + Row::new(vec![ + Span::raw(path), + Span::styled(&self.diag.message, style), + Span::styled(code, style), + ]) + } } } @@ -238,11 +297,16 @@ fn jump_to_location( type SymbolPicker = FilePicker; -fn sym_picker(symbols: Vec, current_path: Option) -> SymbolPicker { +fn sym_picker( + symbols: Vec, + current_path: Option, + editor: &Editor, +) -> SymbolPicker { // TODO: drop current_path comparison and instead use workspace: bool flag? FilePicker::new( symbols, current_path.clone(), + editor.config().icons.picker.then_some(&editor.icons), move |cx, item, action| { let (view, doc) = current!(cx.editor); push_jump(view, doc); @@ -321,6 +385,7 @@ fn diag_picker( FilePicker::new( flat_diag, (styles, format), + cx.editor.config().icons.picker.then_some(&cx.editor.icons), move |cx, PickerDiagnostic { url, @@ -431,8 +496,8 @@ pub fn symbol_picker(cx: &mut Context) { while let Some(mut lsp_items) = futures.try_next().await? { symbols.append(&mut lsp_items); } - let call = move |_editor: &mut Editor, compositor: &mut Compositor| { - let picker = sym_picker(symbols, current_url); + let call = move |editor: &mut Editor, compositor: &mut Compositor| { + let picker = sym_picker(symbols, current_url, editor); compositor.push(Box::new(overlaid(picker))) }; @@ -490,8 +555,8 @@ pub fn workspace_symbol_picker(cx: &mut Context) { cx.jobs.callback(async move { let symbols = initial_symbols.await?; - let call = move |_editor: &mut Editor, compositor: &mut Compositor| { - let picker = sym_picker(symbols, current_url); + let call = move |editor: &mut Editor, compositor: &mut Compositor| { + let picker = sym_picker(symbols, current_url, editor); let dyn_picker = DynamicPicker::new(picker, Box::new(get_symbols)); compositor.push(Box::new(overlaid(dyn_picker))) }; @@ -540,7 +605,7 @@ struct CodeActionOrCommandItem { impl ui::menu::Item for CodeActionOrCommandItem { type Data = (); - fn format(&self, _data: &Self::Data) -> Row { + fn format<'a>(&self, _data: &Self::Data, _icons: Option<&'a Icons>) -> Row { match &self.lsp_item { lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str().into(), lsp::CodeActionOrCommand::Command(command) => command.title.as_str().into(), @@ -763,7 +828,7 @@ pub fn code_action(cx: &mut Context) { impl ui::menu::Item for lsp::Command { type Data = (); - fn format(&self, _data: &Self::Data) -> Row { + fn format<'a>(&self, _data: &Self::Data, _icons: Option<&'a Icons>) -> Row { self.title.as_str().into() } } @@ -1041,6 +1106,7 @@ fn goto_impl( let picker = FilePicker::new( locations, cwdir, + None, move |cx, location, action| { jump_to_location(cx.editor, location, offset_encoding, action) }, diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index b87f374fe6f3..6ebe7b0700b7 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -115,7 +115,7 @@ fn open(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> let callback = async move { let call: job::Callback = job::Callback::EditorCompositor(Box::new( move |editor: &mut Editor, compositor: &mut Compositor| { - let picker = ui::file_picker(path, &editor.config()); + let picker = ui::file_picker(path, &editor.config(), &editor.icons); compositor.push(Box::new(overlaid(picker))); }, )); @@ -1382,9 +1382,10 @@ fn lsp_workspace_command( let callback = async move { let call: job::Callback = Callback::EditorCompositor(Box::new( move |_editor: &mut Editor, compositor: &mut Compositor| { - let picker = ui::Picker::new(commands, (), move |cx, command, _action| { - execute_lsp_command(cx.editor, language_server_id, command.clone()); - }); + let picker = + ui::Picker::new(commands, (), None, move |cx, command, _action| { + execute_lsp_command(cx.editor, language_server_id, command.clone()); + }); compositor.push(Box::new(overlaid(picker))) }, )); diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index d997e8ae1025..e734e9da9c98 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -2,6 +2,7 @@ use crate::compositor::{Component, Context, Event, EventResult}; use helix_view::{ document::SavePoint, editor::CompleteAction, + icons::Icons, theme::{Modifier, Style}, ViewId, }; @@ -33,7 +34,8 @@ impl menu::Item for CompletionItem { .into() } - fn format(&self, _data: &Self::Data) -> menu::Row { + // Before implementing icons for the `CompletionItemKind`s, something must be done to `Menu::required_size` and `Menu::recalculate_size` in order to have correct sizes even with icons. + fn format<'a>(&self, _data: &Self::Data, _icons: Option<&'a Icons>) -> menu::Row { let deprecated = self.item.deprecated.unwrap_or_default() || self.item.tags.as_ref().map_or(false, |tags| { tags.contains(&lsp::CompletionItemTag::DEPRECATED) diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index bdad2e408392..be94eeee8bb6 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -4,29 +4,29 @@ use crate::{ compositor::{Callback, Component, Compositor, Context, Event, EventResult}, ctrl, key, shift, }; -use tui::{buffer::Buffer as Surface, widgets::Table}; +use tui::{buffer::Buffer as Surface, text::Span, widgets::Table}; pub use tui::widgets::{Cell, Row}; use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; use fuzzy_matcher::FuzzyMatcher; -use helix_view::{graphics::Rect, Editor}; +use helix_view::{graphics::Rect, icons::Icons, Editor}; use tui::layout::Constraint; pub trait Item { /// Additional editor state that is used for label calculation. type Data; - fn format(&self, data: &Self::Data) -> Row; + fn format<'a>(&self, data: &Self::Data, icons: Option<&'a Icons>) -> Row; fn sort_text(&self, data: &Self::Data) -> Cow { - let label: String = self.format(data).cell_text().collect(); + let label: String = self.format(data, None).cell_text().collect(); label.into() } fn filter_text(&self, data: &Self::Data) -> Cow { - let label: String = self.format(data).cell_text().collect(); + let label: String = self.format(data, None).cell_text().collect(); label.into() } } @@ -35,11 +35,15 @@ impl Item for PathBuf { /// Root prefix to strip. type Data = PathBuf; - fn format(&self, root_path: &Self::Data) -> Row { - self.strip_prefix(root_path) + fn format<'a>(&self, root_path: &Self::Data, icons: Option<&'a Icons>) -> Row { + let path_str = self + .strip_prefix(root_path) .unwrap_or(self) - .to_string_lossy() - .into() + .to_string_lossy(); + match icons.and_then(|icons| icons.icon_from_path(Some(self))) { + Some(icon) => Row::new([icon.into(), Span::raw(path_str)]), + None => path_str.into(), + } } } @@ -142,10 +146,10 @@ impl Menu { let n = self .options .first() - .map(|option| option.format(&self.editor_data).cells.len()) + .map(|option| option.format(&self.editor_data, None).cells.len()) .unwrap_or_default(); let max_lens = self.options.iter().fold(vec![0; n], |mut acc, option| { - let row = option.format(&self.editor_data); + let row = option.format(&self.editor_data, None); // maintain max for each column for (acc, cell) in acc.iter_mut().zip(row.cells.iter()) { let width = cell.content.width(); @@ -331,7 +335,7 @@ impl Component for Menu { let rows = options .iter() - .map(|option| option.format(&self.editor_data)); + .map(|option| option.format(&self.editor_data, None)); let table = Table::new(rows) .style(style) .highlight_style(selected) diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 4fbcb9b09e53..1a94c3518f6c 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -19,6 +19,7 @@ use crate::filter_picker_entry; use crate::job::{self, Callback}; pub use completion::{Completion, CompletionItem}; pub use editor::EditorView; +use helix_view::icons::Icons; pub use markdown::Markdown; pub use menu::Menu; pub use picker::{DynamicPicker, FileLocation, FilePicker, Picker}; @@ -158,7 +159,11 @@ pub fn regex_prompt( cx.push_layer(Box::new(prompt)); } -pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePicker { +pub fn file_picker( + root: PathBuf, + config: &helix_view::editor::Config, + icons: &Icons, +) -> FilePicker { use ignore::{types::TypesBuilder, WalkBuilder}; use std::time::Instant; @@ -220,6 +225,7 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi FilePicker::new( files, root, + config.icons.picker.then_some(icons), move |cx, path: &PathBuf, action| { if let Err(e) = cx.editor.open(path, action) { let err = if let Some(err) = e.source() { diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index d161f786c4fe..7c2e87478f7c 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -33,6 +33,7 @@ use helix_core::{ use helix_view::{ editor::Action, graphics::{CursorKind, Margin, Modifier, Rect}, + icons::Icons, theme::Style, view::ViewPosition, Document, DocumentId, Editor, @@ -128,11 +129,12 @@ impl FilePicker { pub fn new( options: Vec, editor_data: T::Data, + icons: Option<&'_ Icons>, callback_fn: impl Fn(&mut Context, &T, Action) + 'static, preview_fn: impl Fn(&Editor, &T) -> Option + 'static, ) -> Self { let truncate_start = true; - let mut picker = Picker::new(options, editor_data, callback_fn); + let mut picker = Picker::new(options, editor_data, icons, callback_fn); picker.truncate_start = truncate_start; Self { @@ -465,12 +467,14 @@ pub struct Picker { widths: Vec, callback_fn: PickerCallback, + has_icons: bool, } impl Picker { pub fn new( options: Vec, editor_data: T::Data, + icons: Option<&'_ Icons>, callback_fn: impl Fn(&mut Context, &T, Action) + 'static, ) -> Self { let prompt = Prompt::new( @@ -493,9 +497,10 @@ impl Picker { callback_fn: Box::new(callback_fn), completion_height: 0, widths: Vec::new(), + has_icons: icons.is_some(), }; - picker.calculate_column_widths(); + picker.calculate_column_widths(icons); // scoring on empty input // TODO: just reuse score() @@ -513,23 +518,23 @@ impl Picker { picker } - pub fn set_options(&mut self, new_options: Vec) { + pub fn set_options(&mut self, new_options: Vec, icons: &'_ Icons) { self.options = new_options; self.cursor = 0; self.force_score(); - self.calculate_column_widths(); + self.calculate_column_widths(self.has_icons.then_some(icons)); } /// Calculate the width constraints using the maximum widths of each column /// for the current options. - fn calculate_column_widths(&mut self) { + fn calculate_column_widths(&mut self, icons: Option<&'_ Icons>) { let n = self .options .first() - .map(|option| option.format(&self.editor_data).cells.len()) + .map(|option| option.format(&self.editor_data, icons).cells.len()) .unwrap_or_default(); let max_lens = self.options.iter().fold(vec![0; n], |mut acc, option| { - let row = option.format(&self.editor_data); + let row = option.format(&self.editor_data, icons); // maintain max for each column for (acc, cell) in acc.iter_mut().zip(row.cells.iter()) { let width = cell.content.width(); @@ -820,7 +825,12 @@ impl Component for Picker { .skip(offset) .take(rows as usize) .map(|pmatch| &self.options[pmatch.index]) - .map(|option| option.format(&self.editor_data)) + .map(|option| { + option.format( + &self.editor_data, + cx.editor.config().icons.picker.then_some(&cx.editor.icons), + ) + }) .map(|mut row| { const TEMP_CELL_SEP: &str = " "; @@ -993,7 +1003,7 @@ impl Component for DynamicPicker { Some(overlay) => &mut overlay.content.file_picker.picker, None => return, }; - picker.set_options(new_options); + picker.set_options(new_options, &editor.icons); editor.reset_idle_timer(); })); anyhow::Ok(callback) diff --git a/helix-tui/src/text.rs b/helix-tui/src/text.rs index 076766dd6e14..b9836b3a4527 100644 --- a/helix-tui/src/text.rs +++ b/helix-tui/src/text.rs @@ -49,6 +49,7 @@ use helix_core::line_ending::str_is_line_ending; use helix_core::unicode::width::UnicodeWidthStr; use helix_view::graphics::Style; +use helix_view::icons::Icon; use std::borrow::Cow; use unicode_segmentation::UnicodeSegmentation; @@ -208,6 +209,15 @@ impl<'a> From> for Span<'a> { } } +impl<'a, 'b> From<&'b Icon> for Span<'a> { + fn from(icon: &'b Icon) -> Self { + Span { + content: format!("{}", icon.icon_char).into(), + style: icon.style.unwrap_or_default().into(), + } + } +} + /// A string composed of clusters of graphemes, each with their own style. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Spans<'a>(pub Vec>); From f7d188c1394ed8fea677eff01f63e5d909eec65a Mon Sep 17 00:00:00 2001 From: LazyTanuki <43273245+lazytanuki@users.noreply.github.com> Date: Fri, 10 Mar 2023 19:09:56 +0100 Subject: [PATCH 7/8] feat: handle icons in statusline widget, bufferline and gutter --- helix-term/src/ui/editor.rs | 32 +++++++++++++ helix-term/src/ui/statusline.rs | 81 +++++++++++++++++++++++++++++---- helix-view/src/editor.rs | 3 ++ helix-view/src/gutter.rs | 37 +++++++++------ helix-view/src/icons.rs | 1 + icons.toml | 5 +- runtime/icons/nerdfonts.toml | 7 +-- 7 files changed, 138 insertions(+), 28 deletions(-) diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 43b5d1af6ec3..f241700336c1 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -534,8 +534,24 @@ impl EditorView { let mut x = viewport.x; let current_doc = view!(editor).doc; + let config = editor.config(); + let icons_enabled = config.icons.bufferline; for doc in editor.documents() { + let filetype_icon = doc + .language_config() + .and_then(|config| { + config + .file_types + .iter() + .map(|filetype| match filetype { + helix_core::syntax::FileType::Extension(s) => s, + helix_core::syntax::FileType::Suffix(s) => s, + }) + .find_map(|filetype| editor.icons.icon_from_filetype(filetype)) + }) + .or_else(|| editor.icons.icon_from_path(doc.path())); + let fname = doc .path() .unwrap_or(&scratch) @@ -554,6 +570,22 @@ impl EditorView { let used_width = viewport.x.saturating_sub(x); let rem_width = surface.area.width.saturating_sub(used_width); + if icons_enabled { + if let Some(icon) = filetype_icon { + x = surface + .set_stringn( + x, + viewport.y, + format!(" {}", icon.icon_char), + rem_width as usize, + match icon.style { + Some(s) => style.patch(s.into()), + None => style, + }, + ) + .0; + } + } x = surface .set_stringn(x, viewport.y, text, rem_width as usize, style) .0; diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs index dbf5ac314898..f4d663f2068b 100644 --- a/helix-term/src/ui/statusline.rs +++ b/helix-term/src/ui/statusline.rs @@ -4,6 +4,7 @@ use helix_view::document::DEFAULT_LANGUAGE_NAME; use helix_view::{ document::{Mode, SCRATCH_BUFFER_NAME}, graphics::Rect, + icons::Icon, theme::Style, Document, Editor, View, }; @@ -21,6 +22,7 @@ pub struct RenderContext<'a> { pub focused: bool, pub spinners: &'a ProgressSpinners, pub parts: RenderBuffer<'a>, + pub icons: RenderContextIcons<'a>, } impl<'a> RenderContext<'a> { @@ -31,6 +33,25 @@ impl<'a> RenderContext<'a> { focused: bool, spinners: &'a ProgressSpinners, ) -> Self { + // Determine icon based on language name if possible + let mut filetype_icon = None; + if let Some(language_config) = doc.language_config() { + for filetype in &language_config.file_types { + let filetype_str = match filetype { + helix_core::syntax::FileType::Extension(s) => s, + helix_core::syntax::FileType::Suffix(s) => s, + }; + filetype_icon = editor.icons.icon_from_filetype(filetype_str); + if filetype_icon.is_some() { + break; + } + } + } + // Otherwise based on filetype + if filetype_icon.is_none() { + filetype_icon = editor.icons.icon_from_path(doc.path()) + } + RenderContext { editor, doc, @@ -38,10 +59,21 @@ impl<'a> RenderContext<'a> { focused, spinners, parts: RenderBuffer::default(), + icons: RenderContextIcons { + enabled: editor.config().icons.statusline, + filetype_icon, + vcs_icon: editor.icons.ui.as_ref().and_then(|ui| ui.get("vcs_branch")), + }, } } } +pub struct RenderContextIcons<'a> { + pub enabled: bool, + pub filetype_icon: Option<&'a Icon>, + pub vcs_icon: Option<&'a Icon>, +} + #[derive(Default)] pub struct RenderBuffer<'a> { pub left: Spans<'a>, @@ -148,6 +180,7 @@ where helix_view::editor::StatusLineElement::FileEncoding => render_file_encoding, helix_view::editor::StatusLineElement::FileLineEnding => render_file_line_ending, helix_view::editor::StatusLineElement::FileType => render_file_type, + helix_view::editor::StatusLineElement::FileTypeIcon => render_file_type_icon, helix_view::editor::StatusLineElement::Diagnostics => render_diagnostics, helix_view::editor::StatusLineElement::WorkspaceDiagnostics => render_workspace_diagnostics, helix_view::editor::StatusLineElement::Selections => render_selections, @@ -239,7 +272,13 @@ where if warnings > 0 { write( context, - "●".to_string(), + context + .editor + .icons + .diagnostic + .warning + .icon_char + .to_string(), Some(context.editor.theme.get("warning")), ); write(context, format!(" {} ", warnings), None); @@ -248,7 +287,7 @@ where if errors > 0 { write( context, - "●".to_string(), + context.editor.icons.diagnostic.error.icon_char.to_string(), Some(context.editor.theme.get("error")), ); write(context, format!(" {} ", errors), None); @@ -281,7 +320,13 @@ where if warnings > 0 { write( context, - "●".to_string(), + context + .editor + .icons + .diagnostic + .warning + .icon_char + .to_string(), Some(context.editor.theme.get("warning")), ); write(context, format!(" {} ", warnings), None); @@ -290,7 +335,7 @@ where if errors > 0 { write( context, - "●".to_string(), + context.editor.icons.diagnostic.error.icon_char.to_string(), Some(context.editor.theme.get("error")), ); write(context, format!(" {} ", errors), None); @@ -411,6 +456,21 @@ where write(context, format!(" {} ", file_type), None); } +fn render_file_type_icon(context: &mut RenderContext, write: F) +where + F: Fn(&mut RenderContext, String, Option