diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index ba330cf77237..edf29fa7a9f5 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -10,6 +10,7 @@ - [Migrating from Vim](./from-vim.md) - [Configuration](./configuration.md) - [Themes](./themes.md) + - [Icons](./icons.md) - [Key remapping](./remapping.md) - [Languages](./languages.md) - [Guides](./guides/README.md) diff --git a/book/src/configuration.md b/book/src/configuration.md index 253a07269d18..f9824e80da1d 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -11,6 +11,7 @@ Example config: ```toml theme = "onedark" +icons = "nerdfonts" [editor] line-number = "relative" @@ -108,6 +109,7 @@ The following statusline elements can be configured: | `file-line-ending` | The file line endings (CRLF or LF) | | `total-line-numbers` | The total line numbers of the opened file | | `file-type` | The type of the opened file | +| `file-type-icon` | The icon representing the language of the open file, or else its file type (see `[editor.icons]` section) | | `diagnostics` | The number of warnings and/or errors | | `workspace-diagnostics` | The number of warnings and/or errors on workspace | | `selections` | The number of active selections | @@ -323,6 +325,18 @@ Currently unused Currently unused +### `[editor.icons]` Section + +Option for displaying icons within the editor. + +> Warning: some symbols (such as file-type and symbol-kind icons that you would see in the picker) are not available in the "default" icon set. They usually require a patched font such as [NerdFonts](https://www.nerdfonts.com/) to be installed and configured in your terminal emulator, and the corresponding icon set to be configured in the editor (for example, using `icons = "nerdfonts"` in your configuration file). + +| Key | Description | Default | +| --- | --- | --- | +| `picker` | Whether icons in pickers are enabled. | `true` | +| `bufferline` | Whether icons in the buffer line are enabled. | `true` | +| `statusline` | Whether icons in the status line are enabled. | `true` | + ### `[editor.soft-wrap]` Section Options for soft wrapping lines that exceed the view width: diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index 0f488dc042d3..65564e2aeaca 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -31,6 +31,7 @@ | `:cquit`, `:cq` | Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2). | | `:cquit!`, `:cq!` | Force quit with exit code (default 1) ignoring unsaved changes. Accepts an optional integer exit code (:cq! 2). | | `:theme` | Change the editor theme (show current theme if no name specified). | +| `:icons` | Change the editor icon flavor (show current flavor if no name specified). | | `:clipboard-yank` | Yank main selection into system clipboard. | | `:clipboard-yank-join` | Yank joined selections into system clipboard. A separator can be provided as first argument. Default value is newline. | | `:primary-clipboard-yank` | Yank main selection into system primary clipboard. | diff --git a/book/src/icons.md b/book/src/icons.md new file mode 100644 index 000000000000..a499a9797e77 --- /dev/null +++ b/book/src/icons.md @@ -0,0 +1,140 @@ +# Icons + +## Requirements + +File-type and symbol-kind icons require a patched font such as [NerdFonts](https://www.nerdfonts.com/) to be installed and configured in your terminal emulator. These types of fonts are called *patched* fonts because they define arbitrary symbols for a range of Unicode values, which may vary from one font to another. Therefore, you need to use an icon flavor adapted to your configured terminal font, otherwise you may end up with undefined characters and mismatched icons. + +To enable file-type and symbol-kind icons within the editor, see the `[editor.icons]` section of the [configuration file](./configuration.md). + +To use an icon flavor add `icons = ""` to your [`config.toml`](./configuration.md) at the very top of the file before the first section or select it during runtime using `:icons `. + +## Creating an icon flavor + +Create a file with the name of your icon flavor as file name (i.e `myicons.toml`) and place it in your `icons` directory (i.e `~/.config/helix/icons`). The directory might have to be created beforehand. + +The name "default" is reserved for the builtin icons and cannot be overridden by user defined icons. + +The name of the icon flavor must be set using the `name` key. + +The default icons.toml can be found [here](https://github.com/helix-editor/helix/blob/master/icons.toml), and user submitted icon flavors [here](https://github.com/helix-editor/helix/blob/master/runtime/icons). + +Icons flavors have five sections: + +- Diagnostics +- Breakpoints +- Diff +- Symbol kinds +- Mime types + +Each line in these sections is specified as below: + +```toml +key = { icon = "…", color = "#ff0000" } +``` + +where `key` represents what you want to style, `icon` specifies the character to show as the icon, and `color` specifies the foreground color of the icon. `color` can be omitted to defer to the defaults. + +### Diagnostic icons + +The `[diagnostic]` section defines four **required** diagnostic icons: + +- `error` +- `warning` +- `info` +- `hint` + +These icons appear in the gutter, in the diagnostic pickers as well as in the status line diagnostic component. +By default, they have the foreground color defined in the current theme's corresponding keys. + +> An icon flavor TOML file must define all of these icons. + +### Diff icons + +The `[diff]` section defines three **required** diffing icons: + +- `added` +- `deleted` +- `modified` + +These icons appear in the gutter. +By default, they have the foreground color defined in the current theme's corresponding keys. + +> An icon flavor TOML file must define all of these icons. + +### Breakpoint icons + +The `[breakpoint]` section defines two **required** breakpoint icons: + +- `verified` +- `unverified` + +These icons appear in the gutter while using the Debug Adapter Protocol (DAP). Their color depends on the breakpoint's condition and log message, it cannot be overridden by the `color` key. + +> An icon flavor TOML file must define all of these icons. + +### Symbol kinds icons + +The `[symbol-kind]` section defines **optional** icons for the following required LSP-defined symbol kinds: + +- `file` (this icon is also used on files for which the mime type has not been defined in the next section, as a "generic file" icon) +- `module` +- `namespace` +- `package` +- `class` +- `method` +- `property` +- `field` +- `constructor` +- `enumeration` +- `interface` +- `variable` +- `function` +- `constant` +- `string` +- `number` +- `boolean` +- `array` +- `object` +- `key` +- `null` +- `enum-member` +- `structure` +- `event` +- `operator` +- `type-parameter` + +By default, these icons have the same style as the loaded theme's `keyword` key. Their style can be customized using the `symbolkind` key in the theme configuration file, or it can individually be overridden by their `color` key. + +> An icon flavor TOML file must define either none or all of these icons. + +### Mime types icons + +The `[mime-type]` section defines **optional** icons for mime types or filename, such as: + +```toml +[mime-type] +".bashrc" = { icon = "…", color = "#…" } +"LICENSE" = { icon = "…", color = "#…" } +"rs" = { icon = "…", color = "#…" } +``` + +These icons appear in the file picker, in the statusline `file-type-icon` component, and in the bufferline (when enabled). + +> An icon flavor TOML file can define none, some or all of these icons. + +### Inheritance + +Extend upon other icon flavors by setting the `inherits` property to an existing theme. + +```toml +inherits = "nerdfonts" +name = "custom_nerdfonts" + +# Override the icon for generic files: +[symbol-kind] +file = {icon = "…"} + +# Override the icon for Rust files +[mime-type] +"rs" = { icon = "…", color = "#…" } +``` diff --git a/book/src/themes.md b/book/src/themes.md index 41a3fe101497..9d23908089f6 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -316,14 +316,15 @@ These scopes are used for theming the editor interface: | `ui.cursorline.secondary` | The lines of any other cursors ([if cursorline is enabled][editor-section]) | | `ui.cursorcolumn.primary` | The column of the primary cursor ([if cursorcolumn is enabled][editor-section]) | | `ui.cursorcolumn.secondary` | The columns of any other cursors ([if cursorcolumn is enabled][editor-section]) | -| `warning` | Diagnostics warning (gutter) | -| `error` | Diagnostics error (gutter) | -| `info` | Diagnostics info (gutter) | -| `hint` | Diagnostics hint (gutter) | +| `warning` | Diagnostics warning icon (gutter, statusline, and diagnostic pickers) | +| `error` | Diagnostics error icon (gutter, statusline, and diagnostic pickers) | +| `info` | Diagnostics info icon (gutter, statusline, and diagnostic pickers) | +| `hint` | Diagnostics hint icon (gutter, statusline, and diagnostic pickers) | | `diagnostic` | Diagnostics fallback style (editing area) | | `diagnostic.hint` | Diagnostics hint (editing area) | | `diagnostic.info` | Diagnostics info (editing area) | | `diagnostic.warning` | Diagnostics warning (editing area) | | `diagnostic.error` | Diagnostics error (editing area) | +| `symbolkind` | Symbol kind icons (symbol picker) | [editor-section]: ./configuration.md#editor-section diff --git a/helix-loader/src/lib.rs b/helix-loader/src/lib.rs index ad4ad899db67..f7178e34a453 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,115 @@ 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) +} + +/// 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/application.rs b/helix-term/src/application.rs index 7e9684827a9c..c83f11b6a68e 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 })); @@ -169,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(); @@ -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.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 824abbf4c8f9..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))); }, )); @@ -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], @@ -1358,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))) }, )); @@ -2474,6 +2499,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/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/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/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 ec328ec55cea..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() { @@ -240,7 +246,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 +286,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()); @@ -312,6 +317,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-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-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