From 70deab1b19931f6ce7efaf0446f003496830260d Mon Sep 17 00:00:00 2001 From: Rolo Date: Mon, 30 Dec 2024 04:57:13 -0800 Subject: [PATCH] feat: add support for basic icons --- Cargo.lock | 2 + helix-term/src/commands.rs | 97 +- helix-term/src/commands/lsp.rs | 45 +- helix-term/src/config.rs | 24 + helix-term/src/handlers/document_colors.rs | 5 +- helix-term/src/ui/completion.rs | 28 +- helix-term/src/ui/editor.rs | 45 +- helix-term/src/ui/mod.rs | 28 +- helix-term/src/ui/statusline.rs | 120 ++- .../src/ui/text_decorations/diagnostics.rs | 19 + helix-tui/src/text.rs | 15 + helix-view/Cargo.toml | 1 + helix-view/src/gutter.rs | 48 +- helix-view/src/icons.rs | 969 ++++++++++++++++++ helix-view/src/lib.rs | 1 + 15 files changed, 1366 insertions(+), 81 deletions(-) create mode 100644 helix-view/src/icons.rs diff --git a/Cargo.lock b/Cargo.lock index d7a90210c..af70e75ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1644,6 +1644,7 @@ dependencies = [ "serde", "serde_json", "slotmap", + "smartstring", "tempfile", "thiserror 2.0.12", "tokio", @@ -2577,6 +2578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" dependencies = [ "autocfg", + "serde", "static_assertions", "version_check", ] diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index a3417ea1b..bab7b9c2f 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -14,7 +14,7 @@ use helix_vcs::{FileChange, Hunk}; pub use lsp::*; pub use syntax::*; use tui::{ - text::{Span, Spans}, + text::{Span, Spans, ToSpan}, widgets::Cell, }; pub use typed::*; @@ -46,6 +46,7 @@ use helix_core::{ use helix_view::{ document::{FormatterError, Mode, SCRATCH_BUFFER_NAME}, editor::Action, + icons::ICONS, info::Info, input::KeyEvent, keyboard::KeyCode, @@ -2496,12 +2497,22 @@ fn global_search(cx: &mut Context) { .expect("global search paths are normalized (can't end in `..`)") .to_string_lossy(); - Cell::from(Spans::from(vec![ + let mut spans = Vec::with_capacity(5); + + let icons = ICONS.load(); + + if let Some(icon) = icons.fs().from_path(&path) { + spans.push(icon.to_span_with(|icon| format!("{icon} "))); + } + + spans.extend_from_slice(&[ Span::styled(directories, config.directory_style), Span::raw(filename), Span::styled(":", config.colon_style), Span::styled((item.line_num + 1).to_string(), config.number_style), - ])) + ]); + + Cell::from(Spans::from(spans)) }), PickerColumn::hidden("contents"), ]; @@ -3190,11 +3201,23 @@ fn buffer_picker(cx: &mut Context) { .path .as_deref() .map(helix_stdx::path::get_relative_path); - path.as_deref() + + let name = path + .as_deref() .and_then(Path::to_str) - .unwrap_or(SCRATCH_BUFFER_NAME) - .to_string() - .into() + .unwrap_or(SCRATCH_BUFFER_NAME); + + let icons = ICONS.load(); + + let mut spans = Vec::with_capacity(2); + + if let Some(icon) = icons.fs().from_optional_path(path.as_deref()) { + spans.push(icon.to_span_with(|icon| format!("{icon} "))); + } + + spans.push(name.to_string().into()); + + Spans::from(spans).into() }), ]; let picker = Picker::new(columns, 2, items, (), |cx, meta, action| { @@ -3253,11 +3276,22 @@ fn jumplist_picker(cx: &mut Context) { .path .as_deref() .map(helix_stdx::path::get_relative_path); - path.as_deref() + + let name = path + .as_deref() .and_then(Path::to_str) - .unwrap_or(SCRATCH_BUFFER_NAME) - .to_string() - .into() + .unwrap_or(SCRATCH_BUFFER_NAME); + let icons = ICONS.load(); + + let mut spans = Vec::with_capacity(2); + + if let Some(icon) = icons.fs().from_optional_path(path.as_deref()) { + spans.push(icon.to_span_with(|icon| format!("{icon} "))); + } + + spans.push(name.to_string().into()); + + Spans::from(spans).into() }), ui::PickerColumn::new("flags", |item: &JumpMeta, _| { let mut flags = Vec::new(); @@ -3327,12 +3361,43 @@ fn changed_file_picker(cx: &mut Context) { let columns = [ PickerColumn::new("change", |change: &FileChange, data: &FileChangeData| { + let icons = ICONS.load(); match change { - FileChange::Untracked { .. } => Span::styled("+ untracked", data.style_untracked), - FileChange::Modified { .. } => Span::styled("~ modified", data.style_modified), - FileChange::Conflict { .. } => Span::styled("x conflict", data.style_conflict), - FileChange::Deleted { .. } => Span::styled("- deleted", data.style_deleted), - FileChange::Renamed { .. } => Span::styled("> renamed", data.style_renamed), + FileChange::Untracked { .. } => Span::styled( + match icons.vcs().added() { + Some(icon) => Cow::from(format!("{icon} untracked")), + None => Cow::from("untracked"), + }, + data.style_untracked, + ), + FileChange::Modified { .. } => Span::styled( + match icons.vcs().modified() { + Some(icon) => Cow::from(format!("{icon} modified")), + None => Cow::from("modified"), + }, + data.style_modified, + ), + FileChange::Conflict { .. } => Span::styled( + match icons.vcs().conflict() { + Some(icon) => Cow::from(format!("{icon} conflict")), + None => Cow::from("conflict"), + }, + data.style_conflict, + ), + FileChange::Deleted { .. } => Span::styled( + match icons.vcs().removed() { + Some(icon) => Cow::from(format!("{icon} deleted")), + None => Cow::from("deleted"), + }, + data.style_deleted, + ), + FileChange::Renamed { .. } => Span::styled( + match icons.vcs().renamed() { + Some(icon) => Cow::from(format!("{icon} renamed")), + None => Cow::from("renamed"), + }, + data.style_renamed, + ), } .into() }), diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index ac9dd6e27..e52ec9e44 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -9,7 +9,10 @@ use helix_lsp::{ Client, LanguageServerId, OffsetEncoding, }; use tokio_stream::StreamExt; -use tui::{text::Span, widgets::Row}; +use tui::{ + text::{Span, ToSpan}, + widgets::Row, +}; use super::{align_view, push_jump, Align, Context, Editor}; @@ -22,6 +25,7 @@ use helix_view::{ document::{DocumentInlayHints, DocumentInlayHintsId}, editor::Action, handlers::lsp::SignatureHelpInvoked, + icons::ICONS, theme::Style, Document, View, }; @@ -182,7 +186,7 @@ fn display_symbol_kind(kind: lsp::SymbolKind) -> &'static str { lsp::SymbolKind::OBJECT => "object", lsp::SymbolKind::KEY => "key", lsp::SymbolKind::NULL => "null", - lsp::SymbolKind::ENUM_MEMBER => "enummem", + lsp::SymbolKind::ENUM_MEMBER => "enum_member", lsp::SymbolKind::STRUCT => "struct", lsp::SymbolKind::EVENT => "event", lsp::SymbolKind::OPERATOR => "operator", @@ -242,11 +246,22 @@ fn diag_picker( ui::PickerColumn::new( "severity", |item: &PickerDiagnostic, styles: &DiagnosticStyles| { + let icons = ICONS.load(); match item.diag.severity { - Some(DiagnosticSeverity::HINT) => Span::styled("HINT", styles.hint), - Some(DiagnosticSeverity::INFORMATION) => Span::styled("INFO", styles.info), - Some(DiagnosticSeverity::WARNING) => Span::styled("WARN", styles.warning), - Some(DiagnosticSeverity::ERROR) => Span::styled("ERROR", styles.error), + Some(DiagnosticSeverity::HINT) => { + Span::styled(format!("{} HINT", icons.diagnostic().hint()), styles.hint) + } + Some(DiagnosticSeverity::INFORMATION) => { + Span::styled(format!("{} INFO", icons.diagnostic().info()), styles.info) + } + Some(DiagnosticSeverity::WARNING) => Span::styled( + format!("{} WARN", icons.diagnostic().warning()), + styles.warning, + ), + Some(DiagnosticSeverity::ERROR) => Span::styled( + format!("{} ERROR", icons.diagnostic().error()), + styles.error, + ), _ => Span::raw(""), } .into() @@ -400,7 +415,14 @@ pub fn symbol_picker(cx: &mut Context) { let call = move |_editor: &mut Editor, compositor: &mut Compositor| { let columns = [ ui::PickerColumn::new("kind", |item: &SymbolInformationItem, _| { - display_symbol_kind(item.symbol.kind).into() + let icons = ICONS.load(); + let name = display_symbol_kind(item.symbol.kind); + + if let Some(icon) = icons.kind().get(name) { + icon.to_span_with(|icon| format!("{icon} {name}")).into() + } else { + name.into() + } }), // Some symbols in the document symbol picker may have a URI that isn't // the current file. It should be rare though, so we concatenate that @@ -518,7 +540,14 @@ pub fn workspace_symbol_picker(cx: &mut Context) { }; let columns = [ ui::PickerColumn::new("kind", |item: &SymbolInformationItem, _| { - display_symbol_kind(item.symbol.kind).into() + let icons = ICONS.load(); + let name = display_symbol_kind(item.symbol.kind); + + if let Some(icon) = icons.kind().get(name) { + icon.to_span_with(|icon| format!("{icon} {name}")).into() + } else { + name.into() + } }), ui::PickerColumn::new("name", |item: &SymbolInformationItem, _| { item.symbol.name.as_str().into() diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index bcba8d8e1..615b95ddc 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -2,11 +2,13 @@ use crate::keymap; use crate::keymap::{merge_keys, KeyTrie}; use helix_loader::merge_toml_values; use helix_view::document::Mode; +use helix_view::icons::{Icons, ICONS}; use serde::Deserialize; use std::collections::HashMap; use std::fmt::Display; use std::fs; use std::io::Error as IOError; +use std::sync::Arc; use toml::de::Error as TomlError; #[derive(Debug, Clone, PartialEq)] @@ -22,6 +24,7 @@ pub struct ConfigRaw { pub theme: Option, pub keys: Option>, pub editor: Option, + pub icons: Option, } impl Default for Config { @@ -64,6 +67,7 @@ impl Config { global.and_then(|file| toml::from_str(&file).map_err(ConfigLoadError::BadConfig)); let local_config: Result = local.and_then(|file| toml::from_str(&file).map_err(ConfigLoadError::BadConfig)); + let res = match (global_config, local_config) { (Ok(global), Ok(local)) => { let mut keys = keymap::default(); @@ -84,6 +88,18 @@ impl Config { .map_err(ConfigLoadError::BadConfig)?, }; + let icons: Icons = match (global.icons, local.icons) { + (None, None) => Icons::default(), + (None, Some(val)) | (Some(val), None) => { + val.try_into().map_err(ConfigLoadError::BadConfig)? + } + (Some(global), Some(local)) => merge_toml_values(global, local, 3) + .try_into() + .map_err(ConfigLoadError::BadConfig)?, + }; + + ICONS.store(Arc::new(icons)); + Config { theme: local.theme.or(global.theme), keys, @@ -100,6 +116,14 @@ impl Config { if let Some(keymap) = config.keys { merge_keys(&mut keys, keymap); } + + let icons = config.icons.map_or_else( + || Ok(Icons::default()), + |val| val.try_into().map_err(ConfigLoadError::BadConfig), + )?; + + ICONS.store(Arc::new(icons)); + Config { theme: config.theme, keys, diff --git a/helix-term/src/handlers/document_colors.rs b/helix-term/src/handlers/document_colors.rs index 7813f317e..1968266f8 100644 --- a/helix-term/src/handlers/document_colors.rs +++ b/helix-term/src/handlers/document_colors.rs @@ -8,6 +8,7 @@ use helix_view::{ document::DocumentColorSwatches, events::{DocumentDidChange, DocumentDidOpen, LanguageServerExited, LanguageServerInitialized}, handlers::{lsp::DocumentColorsEvent, Handlers}, + icons::ICONS, DocumentId, Editor, Theme, }; use tokio::time::Instant; @@ -124,9 +125,11 @@ fn attach_document_colors( let mut color_swatches_padding = Vec::with_capacity(doc_colors.len()); let mut colors = Vec::with_capacity(doc_colors.len()); + let icons = ICONS.load(); + for (pos, color) in doc_colors { color_swatches_padding.push(InlineAnnotation::new(pos, " ")); - color_swatches.push(InlineAnnotation::new(pos, "■")); + color_swatches.push(InlineAnnotation::new(pos, icons.kind().color().glyph())); colors.push(Theme::rgb_highlight( (color.red * 255.) as u8, (color.green * 255.) as u8, diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index e17762bfc..2ae30f177 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -9,6 +9,7 @@ use crate::{ use helix_core::snippets::{ActiveSnippet, RenderedSnippet, Snippet}; use helix_core::{self as core, chars, fuzzy::MATCHER, Change, Transaction}; use helix_lsp::{lsp, util, OffsetEncoding}; +use helix_view::icons::ICONS; use helix_view::{ editor::CompleteAction, handlers::lsp::SignatureHelpInvoked, @@ -45,7 +46,7 @@ impl menu::Item for CompletionItem { CompletionItem::Other(core::CompletionItem { label, .. }) => label, }; - let kind = match self { + let mut kind = match self { CompletionItem::Lsp(LspCompletionItem { item, .. }) => match item.kind { Some(lsp::CompletionItemKind::TEXT) => "text".into(), Some(lsp::CompletionItemKind::METHOD) => "method".into(), @@ -78,9 +79,13 @@ impl menu::Item for CompletionItem { }) .and_then(Color::from_hex) .map_or("color".into(), |color| { + let icons = ICONS.load(); Spans::from(vec![ Span::raw("color "), - Span::styled("■", Style::default().fg(color)), + Span::styled( + icons.kind().color().glyph().to_string(), + Style::default().fg(color), + ), ]) }), Some(lsp::CompletionItemKind::FILE) => "file".into(), @@ -101,11 +106,28 @@ impl menu::Item for CompletionItem { CompletionItem::Other(core::CompletionItem { kind, .. }) => kind.as_ref().into(), }; + let icons = ICONS.load(); + let name = &kind.0[0].content; + + let is_folder = kind.0[0].content == "folder"; + + if let Some(icon) = icons.kind().get(name) { + kind.0[0].content = format!("{icon} {name}").into(); + + if let Some(style) = icon.color().map(|color| Style::default().fg(color)) { + kind.0[0].style = style; + } else if is_folder { + kind.0[0].style = *dir_style; + } + } else { + kind.0[0].content = format!("{name}").into(); + } + let label = Span::styled( label, if deprecated { Style::default().add_modifier(Modifier::CROSSED_OUT) - } else if kind.0[0].content == "folder" { + } else if is_folder { *dir_style } else { Style::default() diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 9343d55d4..99347a398 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -24,9 +24,10 @@ use helix_core::{ }; use helix_view::{ annotations::diagnostics::DiagnosticFilter, - document::{Mode, SCRATCH_BUFFER_NAME}, + document::{Mode, DEFAULT_LANGUAGE_NAME, SCRATCH_BUFFER_NAME}, editor::{CompleteAction, CursorShapeConfig}, graphics::{Color, CursorKind, Modifier, Rect, Style}, + icons::ICONS, input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}, keyboard::{KeyCode, KeyModifiers}, Document, Editor, Theme, View, @@ -582,7 +583,7 @@ impl EditorView { let mut x = viewport.x; let current_doc = view!(editor).doc; - for doc in editor.documents() { + for (idx, doc) in editor.documents().enumerate() { let fname = doc .path() .unwrap_or(&scratch) @@ -597,7 +598,45 @@ impl EditorView { bufferline_inactive }; - let text = format!(" {}{} ", fname, if doc.is_modified() { "[+]" } else { "" }); + let lang = doc.language_name().unwrap_or(DEFAULT_LANGUAGE_NAME); + + let icons = ICONS.load(); + + // Render the separator before the text if the current document is not first. + if idx > 0 { + let used_width = viewport.x.saturating_sub(x); + let rem_width = surface.area.width.saturating_sub(used_width); + x = surface + .set_stringn( + x, + viewport.y, + icons.ui().bufferline().separator(), + rem_width as usize, + bufferline_inactive, + ) + .0; + } + + if let Some(icon) = icons + .fs() + .from_optional_path_or_lang(doc.path().map(|path| path.as_path()), lang) + { + let used_width = viewport.x.saturating_sub(x); + let rem_width = surface.area.width.saturating_sub(used_width); + + let style = icon.color().map_or(style, |color| style.fg(color)); + + x = surface + .set_stringn(x, viewport.y, format!(" {icon}"), rem_width as usize, style) + .0; + + if x >= surface.area.right() { + break; + } + } + + let text = format!(" {} {}", fname, if doc.is_modified() { "[+] " } else { "" }); + let used_width = viewport.x.saturating_sub(x); let rem_width = surface.area.width.saturating_sub(used_width); diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 004c88361..d2860328c 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -20,6 +20,7 @@ use crate::job::{self, Callback}; pub use completion::Completion; pub use editor::EditorView; use helix_stdx::rope; +use helix_view::icons::ICONS; use helix_view::theme::Style; pub use markdown::Markdown; pub use menu::Menu; @@ -30,7 +31,7 @@ pub use spinner::{ProgressSpinners, Spinner}; pub use text::Text; use helix_view::Editor; -use tui::text::{Span, Spans}; +use tui::text::{Span, Spans, ToSpan}; use std::path::Path; use std::{error::Error, path::PathBuf}; @@ -249,7 +250,14 @@ pub fn file_picker(editor: &Editor, root: PathBuf) -> FilePicker { "path", |item: &PathBuf, data: &FilePickerData| { let path = item.strip_prefix(&data.root).unwrap_or(item); - let mut spans = Vec::with_capacity(3); + let mut spans = Vec::with_capacity(4); + + let icons = ICONS.load(); + + if let Some(icon) = icons.fs().from_path(path) { + spans.push(icon.to_span_with(|icon| format!("{icon} "))); + } + if let Some(dirs) = path.parent().filter(|p| !p.as_os_str().is_empty()) { spans.extend([ Span::styled(dirs.to_string_lossy(), data.directory_style), @@ -310,8 +318,22 @@ pub fn file_explorer(root: PathBuf, editor: &Editor) -> Result { pub editor: &'a Editor, @@ -234,29 +235,48 @@ where counts }); + let icons = ICONS.load(); for sev in &context.editor.config().statusline.diagnostics { match sev { Severity::Hint if hints > 0 => { - write(context, Span::styled("●", context.editor.theme.get("hint"))); - write(context, format!(" {} ", hints).into()); + write( + context, + Span::styled( + icons.diagnostic().hint().to_string(), + context.editor.theme.get("hint"), + ), + ); + write(context, Span::raw(format!(" {hints} "))); } Severity::Info if info > 0 => { - write(context, Span::styled("●", context.editor.theme.get("info"))); - write(context, format!(" {} ", info).into()); + write( + context, + Span::styled( + icons.diagnostic().info().to_string(), + context.editor.theme.get("info"), + ), + ); + write(context, Span::raw(format!(" {info} "))); } Severity::Warning if warnings > 0 => { write( context, - Span::styled("●", context.editor.theme.get("warning")), + Span::styled( + icons.diagnostic().warning().to_string(), + context.editor.theme.get("warning"), + ), ); - write(context, format!(" {} ", warnings).into()); + write(context, Span::raw(format!(" {warnings} "))); } Severity::Error if errors > 0 => { write( context, - Span::styled("●", context.editor.theme.get("error")), + Span::styled( + icons.diagnostic().error().to_string(), + context.editor.theme.get("error"), + ), ); - write(context, format!(" {} ", errors).into()); + write(context, Span::raw(format!(" {errors} "))); } _ => {} } @@ -287,10 +307,10 @@ where }, ); - let sevs_to_show = &context.editor.config().statusline.workspace_diagnostics; + let sevs = &context.editor.config().statusline.workspace_diagnostics; - // Avoid showing the " W " if no diagnostic counts will be shown. - if !sevs_to_show.iter().any(|sev| match sev { + // Avoid showing the ` W ` if no diagnostic counts will be shown. + if !sevs.iter().any(|sev| match sev { Severity::Hint => hints != 0, Severity::Info => info != 0, Severity::Warning => warnings != 0, @@ -299,31 +319,57 @@ where return; } - write(context, " W ".into()); + let icons = ICONS.load(); - for sev in sevs_to_show { + let icon = icons.ui().workspace(); + + // Special case when the `workspace` key is set to `""`: + // - This will remove the default ` W ` so that the rest of the icons are spaced correctly. + if !icon.glyph().is_empty() { + write(context, icon.to_span_with(|icon| format!(" {icon} "))); + } + + for sev in sevs { match sev { Severity::Hint if hints > 0 => { - write(context, Span::styled("●", context.editor.theme.get("hint"))); - write(context, format!(" {} ", hints).into()); + write( + context, + Span::styled( + icons.diagnostic().hint().to_string(), + context.editor.theme.get("hint"), + ), + ); + write(context, Span::raw(format!(" {hints} "))); } Severity::Info if info > 0 => { - write(context, Span::styled("●", context.editor.theme.get("info"))); - write(context, format!(" {} ", info).into()); + write( + context, + Span::styled( + format!(" {} ", icons.diagnostic().info()), + context.editor.theme.get("info"), + ), + ); + write(context, Span::raw(format!(" {info} "))); } Severity::Warning if warnings > 0 => { write( context, - Span::styled("●", context.editor.theme.get("warning")), + Span::styled( + icons.diagnostic().warning().to_string(), + context.editor.theme.get("warning"), + ), ); - write(context, format!(" {} ", warnings).into()); + write(context, Span::raw(format!(" {warnings} "))); } Severity::Error if errors > 0 => { write( context, - Span::styled("●", context.editor.theme.get("error")), + Span::styled( + icons.diagnostic().error().to_string(), + context.editor.theme.get("error"), + ), ); - write(context, format!(" {} ", errors).into()); + write(context, Span::raw(format!(" {errors} "))); } _ => {} } @@ -440,9 +486,18 @@ fn render_file_type<'a, F>(context: &mut RenderContext<'a>, write: F) where F: Fn(&mut RenderContext<'a>, Span<'a>) + Copy, { - let file_type = context.doc.language_name().unwrap_or(DEFAULT_LANGUAGE_NAME); + let lang = context.doc.language_name().unwrap_or(DEFAULT_LANGUAGE_NAME); - write(context, format!(" {} ", file_type).into()); + let icons = ICONS.load(); + + if let Some(icon) = icons + .fs() + .from_optional_path_or_lang(context.doc.path().map(|path| path.as_path()), lang) + { + write(context, icon.to_span_with(|icon| format!(" {icon} "))); + } else { + write(context, format!(" {lang} ").into()); + } } fn render_file_name<'a, F>(context: &mut RenderContext<'a>, write: F) @@ -539,13 +594,18 @@ fn render_version_control<'a, F>(context: &mut RenderContext<'a>, write: F) where F: Fn(&mut RenderContext<'a>, Span<'a>) + Copy, { - let head = context - .doc - .version_control_head() - .unwrap_or_default() - .to_string(); + let head = context.doc.version_control_head().unwrap_or_default(); - write(context, head.into()); + if !head.is_empty() { + let icons = ICONS.load(); + + let vcs = match icons.vcs().branch() { + Some(icon) => format!(" {icon} {head} "), + None => format!(" {head} "), + }; + + write(context, vcs.into()); + } } fn render_register<'a, F>(context: &mut RenderContext<'a>, write: F) diff --git a/helix-term/src/ui/text_decorations/diagnostics.rs b/helix-term/src/ui/text_decorations/diagnostics.rs index fb82bcf54..23dd9e759 100644 --- a/helix-term/src/ui/text_decorations/diagnostics.rs +++ b/helix-term/src/ui/text_decorations/diagnostics.rs @@ -9,6 +9,7 @@ use helix_view::annotations::diagnostics::{ DiagnosticFilter, InlineDiagnosticAccumulator, InlineDiagnosticsConfig, }; +use helix_view::icons::ICONS; use helix_view::theme::Style; use helix_view::{Document, Theme}; @@ -102,6 +103,24 @@ impl Renderer<'_, '_> { let mut end_col = start_col; let mut draw_col = (col + 1) as u16; + // Draw the diagnostic indicator: + if !self.renderer.column_in_bounds(draw_col as usize, 2) { + return 0; + } + + let icons = ICONS.load(); + + let symbol = match diag.severity() { + Severity::Hint => icons.diagnostic().hint(), + Severity::Info => icons.diagnostic().info(), + Severity::Warning => icons.diagnostic().warning(), + Severity::Error => icons.diagnostic().error(), + }; + + self.renderer + .set_string(self.renderer.viewport.x + draw_col, row, symbol, style); + draw_col += 2; + for line in diag.message.lines() { if !self.renderer.column_in_bounds(draw_col as usize, 1) { break; diff --git a/helix-tui/src/text.rs b/helix-tui/src/text.rs index 1317a0095..7b2dad406 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,20 @@ impl<'a> From> for Span<'a> { } } +pub trait ToSpan<'a> { + fn to_span_with(&self, content: impl Fn(&Self) -> String) -> Span<'a>; +} + +impl<'a> ToSpan<'a> for Icon { + fn to_span_with(&self, content: impl Fn(&Self) -> String) -> Span<'a> { + if let Some(style) = self.color().map(|color| Style::default().fg(color)) { + Span::styled(content(self), style) + } else { + Span::raw(content(self)) + } + } +} + /// A string composed of clusters of graphemes, each with their own style. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Spans<'a>(pub Vec>); diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index 3154788d0..da07a0a01 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -53,6 +53,7 @@ parking_lot.workspace = true thiserror.workspace = true kstring = "2.0" +smartstring = { version = "1.0.1", features = ["serde"]} [target.'cfg(windows)'.dependencies] clipboard-win = { version = "5.4", features = ["std"] } diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs index c2cbc0da5..f0e749d96 100644 --- a/helix-view/src/gutter.rs +++ b/helix-view/src/gutter.rs @@ -5,6 +5,7 @@ use helix_core::syntax::config::LanguageServerFeature; use crate::{ editor::GutterType, graphics::{Style, UnderlineStyle}, + icons::ICONS, Document, Editor, Theme, View, }; @@ -46,7 +47,6 @@ impl GutterType { } pub fn diagnostic<'doc>( - _editor: &'doc Editor, doc: &'doc Document, _view: &View, theme: &Theme, @@ -74,15 +74,20 @@ pub fn diagnostic<'doc>( .any(|ls| ls.id() == id) }) }); - diagnostics_on_line.max_by_key(|d| d.severity).map(|d| { - write!(out, "●").ok(); - match d.severity { - Some(Severity::Error) => error, - Some(Severity::Warning) | None => warning, - Some(Severity::Info) => info, - Some(Severity::Hint) => hint, - } - }) + + diagnostics_on_line + .max_by_key(|d| d.severity) + .map(move |d| { + let icons = ICONS.load(); + let (style, symbol) = match d.severity { + Some(Severity::Error) => (error, icons.diagnostic().error()), + Some(Severity::Warning) | None => (warning, icons.diagnostic().warning()), + Some(Severity::Info) => (info, icons.diagnostic().info()), + Some(Severity::Hint) => (hint, icons.diagnostic().hint()), + }; + out.push_str(symbol); + style + }) }, ) } @@ -119,15 +124,17 @@ pub fn diff<'doc>( return None; } + let icons = ICONS.load(); + let (icon, style) = if hunk.is_pure_insertion() { - ("▍", added) + (icons.gutter().added(), added) } else if hunk.is_pure_removal() { if !first_visual_line { return None; } - ("▔", deleted) + (icons.gutter().removed(), deleted) } else { - ("▍", modified) + (icons.gutter().modified(), modified) }; write!(out, "{}", icon).unwrap(); @@ -264,7 +271,13 @@ pub fn breakpoints<'doc>( breakpoint_style }; - let sym = if breakpoint.verified { "●" } else { "◯" }; + let icons = ICONS.load(); + + let sym = if breakpoint.verified { + icons.dap().verified() + } else { + icons.dap().unverified() + }; write!(out, "{}", sym).unwrap(); Some(style) }, @@ -299,8 +312,9 @@ fn execution_pause_indicator<'doc>( return None; } - let sym = "▶"; - write!(out, "{}", sym).unwrap(); + let icons = ICONS.load(); + + write!(out, "{}", icons.dap().play()).unwrap(); Some(style) }, ) @@ -313,7 +327,7 @@ pub fn diagnostics_or_breakpoints<'doc>( theme: &Theme, is_focused: bool, ) -> GutterFn<'doc> { - let mut diagnostics = diagnostic(editor, doc, view, theme, is_focused); + let mut diagnostics = diagnostic(doc, view, theme, is_focused); let mut breakpoints = breakpoints(editor, doc, view, theme, is_focused); let mut execution_pause_indicator = execution_pause_indicator(editor, doc, theme, is_focused); diff --git a/helix-view/src/icons.rs b/helix-view/src/icons.rs new file mode 100644 index 000000000..6f1c8a679 --- /dev/null +++ b/helix-view/src/icons.rs @@ -0,0 +1,969 @@ +use arc_swap::ArcSwap; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, fmt::Display, path::Path}; + +use smartstring::{LazyCompact, SmartString}; + +use crate::theme::Color; + +type String = SmartString; + +pub static ICONS: Lazy> = Lazy::new(ArcSwap::default); + +/// Centralized location for icons that can be used throughout the UI. +#[derive(Debug, Default, Deserialize, PartialEq, Eq, Clone)] +#[serde(default, deny_unknown_fields)] +pub struct Icons { + fs: Fs, + kind: Kind, + diagnostic: Diagnostic, + vcs: Vcs, + dap: Dap, + gutter: Gutter, + ui: Ui, +} + +impl Icons { + #[inline] + pub fn fs(&self) -> &Fs { + &self.fs + } + + #[inline] + pub fn kind(&self) -> &Kind { + &self.kind + } + + #[inline] + pub fn diagnostic(&self) -> &Diagnostic { + &self.diagnostic + } + + #[inline] + pub fn vcs(&self) -> &Vcs { + &self.vcs + } + + #[inline] + pub fn dap(&self) -> &Dap { + &self.dap + } + + #[inline] + pub fn gutter(&self) -> &Gutter { + &self.gutter + } + + #[inline] + pub fn ui(&self) -> &Ui { + &self.ui + } +} + +#[derive(Debug, Deserialize, Default, PartialEq, Eq, Clone)] +pub struct Kind { + enabled: bool, + + file: Option, + folder: Option, + text: Option, + module: Option, + namespace: Option, + package: Option, + class: Option, + method: Option, + property: Option, + field: Option, + constructor: Option, + #[serde(rename = "enum")] + r#enum: Option, + interface: Option, + function: Option, + variable: Option, + constant: Option, + string: Option, + number: Option, + boolean: Option, + array: Option, + object: Option, + key: Option, + null: Option, + enum_member: Option, + #[serde(rename = "struct")] + r#struct: Option, + event: Option, + operator: Option, + type_parameter: Option, + color: Option, + keyword: Option, + value: Option, + snippet: Option, + reference: Option, + unit: Option, + word: Option, + spellcheck: Option, +} + +impl Kind { + #[inline] + #[must_use] + pub fn get(&self, kind: &str) -> Option { + if !self.enabled { + return None; + } + + let icon = match kind { + "file" => self.file()?, + "folder" => self.folder()?, + "module" => self.module()?, + "namespace" => self.namespace()?, + "package" => self.package()?, + "class" => self.class()?, + "method" => self.method()?, + "property" => self.property()?, + "field" => self.field()?, + "construct" => self.constructor()?, + "enum" => self.r#enum()?, + "interface" => self.interface()?, + "function" => self.function()?, + "variable" => self.variable()?, + "constant" => self.constant()?, + "string" => self.string()?, + "number" => self.number()?, + "boolean" => self.boolean()?, + "array" => self.array()?, + "object" => self.object()?, + "key" => self.key()?, + "null" => self.null()?, + "enum_member" => self.enum_member()?, + "struct" => self.r#struct()?, + "event" => self.event()?, + "operator" => self.operator()?, + "typeparam" => self.type_parameter()?, + "color" => self.color(), + "keyword" => self.keyword()?, + "value" => self.value()?, + "snippet" => self.snippet()?, + "reference" => self.reference()?, + "text" => self.text()?, + "unit" => self.unit()?, + "word" => self.word()?, + "spellcheck" => self.spellcheck()?, + + _ => return None, + }; + + Some(icon) + } + + #[inline] + pub fn file(&self) -> Option { + if !self.enabled { + return None; + } + self.file.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn folder(&self) -> Option { + if !self.enabled { + return None; + } + self.folder.clone().or_else(|| Some(Icon::from("󰉋"))) + } + + #[inline] + pub fn module(&self) -> Option { + if !self.enabled { + return None; + } + self.module.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn namespace(&self) -> Option { + if !self.enabled { + return None; + } + self.namespace.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn package(&self) -> Option { + if !self.enabled { + return None; + } + self.package.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn class(&self) -> Option { + if !self.enabled { + return None; + } + self.class.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn method(&self) -> Option { + if !self.enabled { + return None; + } + self.method.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn property(&self) -> Option { + if !self.enabled { + return None; + } + self.property.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn field(&self) -> Option { + if !self.enabled { + return None; + } + self.field.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn constructor(&self) -> Option { + if !self.enabled { + return None; + } + self.constructor.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn r#enum(&self) -> Option { + if !self.enabled { + return None; + } + self.r#enum.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn interface(&self) -> Option { + if !self.enabled { + return None; + } + self.interface.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn function(&self) -> Option { + if !self.enabled { + return None; + } + self.function.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn variable(&self) -> Option { + if !self.enabled { + return None; + } + self.variable.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn constant(&self) -> Option { + if !self.enabled { + return None; + } + self.constant.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn string(&self) -> Option { + if !self.enabled { + return None; + } + self.string.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn number(&self) -> Option { + if !self.enabled { + return None; + } + self.number.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn boolean(&self) -> Option { + if !self.enabled { + return None; + } + self.boolean.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn array(&self) -> Option { + if !self.enabled { + return None; + } + self.array.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn object(&self) -> Option { + if !self.enabled { + return None; + } + self.object.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn key(&self) -> Option { + if !self.enabled { + return None; + } + self.key.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn null(&self) -> Option { + if !self.enabled { + return None; + } + self.null.clone().or_else(|| Some(Icon::from("󰟢"))) + } + + #[inline] + pub fn enum_member(&self) -> Option { + if !self.enabled { + return None; + } + self.enum_member.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn r#struct(&self) -> Option { + if !self.enabled { + return None; + } + self.r#struct.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn event(&self) -> Option { + if !self.enabled { + return None; + } + self.event.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn operator(&self) -> Option { + if !self.enabled { + return None; + } + self.operator.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn type_parameter(&self) -> Option { + if !self.enabled { + return None; + } + self.type_parameter + .clone() + .or_else(|| Some(Icon::from(""))) + } + + // Always enabled + #[inline] + pub fn color(&self) -> Icon { + self.color.clone().unwrap_or_else(|| Icon::from("■")) + } + + #[inline] + pub fn keyword(&self) -> Option { + if !self.enabled { + return None; + } + self.keyword.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn value(&self) -> Option { + if !self.enabled { + return None; + } + self.value.clone().or_else(|| Some(Icon::from("󰎠"))) + } + + #[inline] + pub fn snippet(&self) -> Option { + if !self.enabled { + return None; + } + self.snippet.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn reference(&self) -> Option { + if !self.enabled { + return None; + } + self.reference.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn text(&self) -> Option { + if !self.enabled { + return None; + } + self.text.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn unit(&self) -> Option { + if !self.enabled { + return None; + } + self.unit.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn word(&self) -> Option { + if !self.enabled { + return None; + } + self.word.clone().or_else(|| Some(Icon::from(""))) + } + + #[inline] + pub fn spellcheck(&self) -> Option { + if !self.enabled { + return None; + } + self.spellcheck.clone().or_else(|| Some(Icon::from("󰓆"))) + } +} + +#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Default)] +pub struct Diagnostic { + hint: Option, + info: Option, + warning: Option, + error: Option, +} + +impl Diagnostic { + #[inline] + pub fn hint(&self) -> &str { + self.hint.as_deref().unwrap_or("○") + } + + #[inline] + pub fn info(&self) -> &str { + self.info.as_deref().unwrap_or("●") + } + + #[inline] + pub fn warning(&self) -> &str { + self.warning.as_deref().unwrap_or("▲") + } + + #[inline] + pub fn error(&self) -> &str { + self.error.as_deref().unwrap_or("■") + } +} + +#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Default)] +pub struct Vcs { + enabled: bool, + branch: Option, + added: Option, + removed: Option, + ignored: Option, + modified: Option, + renamed: Option, + conflict: Option, +} + +impl Vcs { + #[inline] + pub fn branch(&self) -> Option<&str> { + if !self.enabled { + return None; + } + self.branch.as_deref().or(Some("")) + } + + #[inline] + pub fn added(&self) -> Option<&str> { + if !self.enabled { + return None; + } + self.added.as_deref().or(Some("")) + } + + #[inline] + pub fn removed(&self) -> Option<&str> { + if !self.enabled { + return None; + } + self.removed.as_deref().or(Some("")) + } + + #[inline] + pub fn ignored(&self) -> Option<&str> { + if !self.enabled { + return None; + } + self.ignored.as_deref().or(Some("")) + } + + #[inline] + pub fn modified(&self) -> Option<&str> { + if !self.enabled { + return None; + } + self.modified.as_deref().or(Some("")) + } + + #[inline] + pub fn renamed(&self) -> Option<&str> { + if !self.enabled { + return None; + } + self.renamed.as_deref().or(Some("")) + } + + #[inline] + pub fn conflict(&self) -> Option<&str> { + if !self.enabled { + return None; + } + self.conflict.as_deref().or(Some("")) + } +} + +#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Default)] +pub struct Fs { + enabled: bool, + directory: Option, + #[serde(rename = "directory-open")] + directory_open: Option, + #[serde(flatten)] + mime: HashMap, +} + +macro_rules! mimes { + ( $( $key:literal => { glyph: $glyph:expr $(, color: $color:expr)? } ),* $(,)? ) => {{ + let mut map = HashMap::new(); + $( + map.insert(String::from($key), Icon { + glyph: String::from($glyph), + color: None $(.or( Some(Color::from_hex($color).unwrap())) )?, + }); + )* + map + }}; +} + +static MIMES: once_cell::sync::Lazy> = once_cell::sync::Lazy::new(|| { + mimes! { + // Language name + "git-commit" => {glyph: "", color: "#f15233" }, + "git-rebase" => {glyph: "", color: "#f15233" }, + "git-config" => {glyph: "", color: "#f15233" }, + "helm" => {glyph: "", color: "#277a9f" }, + "nginx" => {glyph: "", color: "#019639" }, + "text" => { glyph: "" }, + + // Exact + "README.md" => { glyph: "" }, + "LICENSE" => { glyph: "󰗑", color: "#e7a933" }, + "CHANGELOG.md" => { glyph: "", color: "#7bab43" }, + ".gitignore" => { glyph: "", color: "#f15233" }, + ".gitattributes" => { glyph: "", color: "#f15233" }, + ".editorconfig" => { glyph: "" }, + ".env" => { glyph: "" }, + ".dockerignore" => {glyph: "󰡨", color: "#0096e6" }, + ".ignore" => {glyph: "󰈉" }, + "docker-compose.yaml" => {glyph: "󰡨", color: "#0096e6" }, + "compose.yaml" => {glyph: "󰡨", color: "#0096e6" }, + "Makefile" => {glyph: "" }, + "Dockerfile" => {glyph: "󰡨", color: "#0096e6" }, + + // Extension + "rs" => {glyph: "󱘗", color: "#fab387" }, + "py" => {glyph: "󰌠", color: "#ffd94a" }, + "c" => {glyph: "", color: "#b0c4de" }, + "cpp" => {glyph: "", color: "#0288d1" }, + "cs" => {glyph: "", color: "#512bd4" }, + "d" => {glyph: "", color: "#b03931" }, + "ex" => {glyph: "", color: "#71567d" }, + "fs" => {glyph: "", color: "#2fb9da" }, + "go" => {glyph: "󰟓", color: "#00acd8" }, + "hs" => {glyph: "󰲒", color: "#5e5089" }, + "java" => {glyph: "󰬷", color: "#f58217" }, + "js" => {glyph: "󰌞", color: "#f0dc4e" }, + "ts" => {glyph: "󰛦", color: "#3179c7" }, + "kt" => {glyph: "󱈙", color: "#8a48fc" }, + "html" => {glyph: "󰌝", color: "#f15c29" }, + "css" => {glyph: "󰌜", color: "#9479b6" }, + "scss" => {glyph: "󰟬", color: "#d06599" }, + "sh" => {glyph: "" }, + "bash" => {glyph: "" }, + "php" => {glyph: "󰌟", color: "#777bb3" }, + "ps1" => {glyph: "󰨊", color: "#2670be" }, + "dart" => {glyph: "", color: "#2db7f6" }, + "ruby" => {glyph: "󰴭", color: "#d30000" }, + "swift" => {glyph: "󰛥", color: "#fba03d" }, + "r" => {glyph: "󰟔", color: "#236abd" }, + "groovy" => {glyph: "", color: "#4298b8" }, + "scala" => {glyph: "", color: "#db3331" }, + "pl" => {glyph: "", color: "#006894" }, + "clj" => {glyph: "", color: "#91b4ff" }, + "jl" => {glyph: "", color: "#cb3c33" }, + "zig" => {glyph: "", color: "#f7a41d" }, + "f" => {glyph: "󱈚", color: "#734f96" }, + "erl" => {glyph: "", color: "#a90432" }, + "ml" => {glyph: "", color: "#f29000" }, + "cr" => {glyph: "" }, + "svelte" => {glyph: "", color: "#ff5620" }, + "gd" => {glyph: "", color: "#478cbf" }, + "nim" => {glyph: "", color: "#efc743" }, + "jsx" => {glyph: "", color: "#61dafb" }, + "tsx" => {glyph: "", color: "#61dafb" }, + "twig" => {glyph: "", color: "#a8bf21" }, + "lua" => {glyph: "", color: "#74c7ec" }, + "vue" => {glyph: "", color: "#40b884" }, + "lisp" => {glyph: "" }, + "elm" => {glyph: "", color: "#5b6379" }, + "res" => {glyph: "", color: "#ef5350" }, + "sol" => {glyph: "" }, + "vala" => {glyph: "", color: "#a972e4" }, + "scm" => {glyph: "", color: "#d53d32" }, + "v" => {glyph: "", color: "#5e87c0" }, + "prisma" => {glyph: "" }, + "ada" => {glyph: "", color: "#195c19" }, + "astro" => {glyph: "", color: "#ed45cf" }, + "m" => {glyph: "", color: "#ed8012" }, + "rst" => {glyph: "", color: "#74aada" }, + "cl" => {glyph: "" }, + "njk" => {glyph: "", color: "#53a553" }, + "jinja" => {glyph: "" }, + "bicep" => {glyph: "", color: "#529ab7" }, + "wat" => {glyph: "", color: "#644fef" }, + "md" => {glyph: "" }, + "make" => {glyph: "" }, + "cmake" => {glyph: "", color: "#3eae2b" }, + "nix" => {glyph: "", color: "#4f73bd" }, + "awk" => {glyph: "" }, + "ll" => {glyph: "", color: "#09627d" }, + "regex" => {glyph: "" }, + "gql" => {glyph: "", color: "#e534ab" }, + "typst" => {glyph: "", color: "#5bc0af" }, + "json" => {glyph: "" }, + "toml" => {glyph: "", color: "#a8403e" }, + "xml" => {glyph: "󰗀", color: "#8bc34a" }, + "tex" => {glyph: "", color: "#008080" }, + "todotxt" => {glyph: "", color: "#7cb342" }, + "svg" => {glyph: "󰜡", color: "#ffb300" }, + "png" => {glyph: "", color: "#26a69a" }, + "jpeg" => {glyph: "", color: "#26a69a" }, + "jpg" => {glyph: "", color: "#26a69a" }, + "lock" => {glyph: "", color: "#70797d" }, + "csv" => {glyph: "", color: "#1abb54" }, + "ipynb" => {glyph: "", color: "#f47724" }, + "ttf" => {glyph: "", color: "#144cb7" }, + "exe" => {glyph: "" }, + "bin" => {glyph: "" }, + "bzl" => {glyph: "", color: "#76d275" }, + "sql" => {glyph: "" }, + "db" => {glyph: "" }, + "yaml" => { glyph: "", color: "#cc1018" }, + "yml" => { glyph: "", color: "#cc1018" }, + "conf" => { glyph: "" }, + } +}); + +impl Fs { + #[inline] + pub fn directory(&self, is_open: bool) -> Option<&str> { + if !self.enabled { + return None; + } + + let dir = if is_open { + self.directory_open.as_deref().unwrap_or("󰝰") + } else { + self.directory.as_deref().unwrap_or("󰉋") + }; + + Some(dir) + } + + #[inline] + pub fn from_name<'a>(&'a self, name: &str) -> Option<&'a Icon> { + if !self.enabled { + return None; + } + + self.mime.get(name).or_else(|| MIMES.get(name)) + } + + #[inline] + pub fn from_path<'b, 'a: 'b>(&'a self, path: &'b Path) -> Option<&'b Icon> { + self.__from_path_or_lang(Some(path), None) + } + + #[inline] + pub fn from_optional_path<'b, 'a: 'b>(&'a self, path: Option<&'b Path>) -> Option<&'b Icon> { + self.__from_path_or_lang(path, None) + } + + #[inline] + pub fn from_path_or_lang<'b, 'a: 'b>( + &'a self, + path: &'b Path, + lang: &'b str, + ) -> Option<&'b Icon> { + self.__from_path_or_lang(Some(path), Some(lang)) + } + + #[inline] + pub fn from_optional_path_or_lang<'b, 'a: 'b>( + &'a self, + path: Option<&'b Path>, + lang: &'b str, + ) -> Option<&'b Icon> { + self.__from_path_or_lang(path, Some(lang)) + } + + fn __from_path_or_lang<'b, 'a: 'b>( + &'a self, + path: Option<&'b Path>, + lang: Option<&'b str>, + ) -> Option<&'b Icon> { + if !self.enabled { + return None; + } + + if let Some(path) = path { + // Search for fully specified name first so that custom icons, + // for example for `README.md` or `docker-compose.yaml`, can + // take precedence over any extension it may have. + if let Some(Some(name)) = path.file_name().map(|name| name.to_str()) { + // Search config options first, then built-in. + if let Some(icon) = self.mime.get(name).or_else(|| MIMES.get(name)) { + return Some(icon); + } + } + + // Try to search for icons based off of the extension. + if let Some(Some(ext)) = path.extension().map(|ext| ext.to_str()) { + // Search config options first, then built-in. + if let Some(icon) = self.mime.get(ext).or_else(|| MIMES.get(ext)) { + return Some(icon); + } + } + } + + // Try to search via lang name. + if let Some(lang) = lang { + // Search config options first, then built-in. + if let Some(icon) = self.mime.get(lang).or_else(|| MIMES.get(lang)) { + return Some(icon); + } + } + + // If icons are enabled but there is no matching found, default to the `text` icon. + // Check user configured first, then built-in. + self.mime.get("text").or_else(|| MIMES.get("text")) + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Default)] +pub struct Dap { + verified: Option, + unverified: Option, + play: Option, +} + +impl Dap { + #[inline] + pub fn verified(&self) -> &str { + self.verified.as_deref().unwrap_or("●") + } + + #[inline] + pub fn unverified(&self) -> &str { + self.unverified.as_deref().unwrap_or("◯") + } + + #[inline] + pub fn play(&self) -> &str { + self.play.as_deref().unwrap_or("▶") + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Default)] +pub struct Gutter { + added: Option, + modified: Option, + removed: Option, +} + +impl Gutter { + #[inline] + pub fn added(&self) -> &str { + self.added.as_deref().unwrap_or("▍") + } + + #[inline] + pub fn modified(&self) -> &str { + self.modified.as_deref().unwrap_or("▍") + } + + #[inline] + pub fn removed(&self) -> &str { + self.removed.as_deref().unwrap_or("▔") + } +} + +#[derive(Debug, Default, PartialEq, Eq, Clone)] +pub struct Icon { + glyph: String, + color: Option, +} + +#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Default)] +#[serde(default, deny_unknown_fields)] +pub struct Ui { + workspace: Option, + bufferline: BufferLine, +} + +impl Ui { + /// Returns a workspace diagnostic icon. + /// + /// If no icon is set in the config, it will return `W` by default. + #[inline] + pub fn workspace(&self) -> Icon { + self.workspace.clone().unwrap_or_else(|| Icon::from("W")) + } + + #[inline] + pub fn bufferline(&self) -> &BufferLine { + &self.bufferline + } +} + +#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Default)] +pub struct BufferLine { + separator: Option, +} + +impl BufferLine { + #[inline] + pub fn separator(&self) -> &str { + self.separator.as_deref().unwrap_or("│") + } +} + +impl Icon { + pub fn glyph(&self) -> &str { + self.glyph.as_str() + } + + pub const fn color(&self) -> Option { + self.color + } +} + +impl From<&str> for Icon { + fn from(icon: &str) -> Self { + Self { + glyph: String::from(icon), + color: None, + } + } +} + +impl Display for Icon { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.glyph) + } +} + +impl<'de> Deserialize<'de> for Icon { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(IconVisitor) + } +} + +struct IconVisitor; + +impl<'de> serde::de::Visitor<'de> for IconVisitor { + type Value = Icon; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + formatter, + "a string glyph or a map with 'glyph' and optional 'color'" + ) + } + + fn visit_str(self, glyph: &str) -> Result + where + E: serde::de::Error, + { + Ok(Icon { + glyph: String::from(glyph), + color: None, + }) + } + + fn visit_map(self, mut map: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + let mut glyph = None; + let mut color = None; + + while let Some(key) = map.next_key::()? { + match key.as_str() { + "glyph" => { + if glyph.is_some() { + return Err(serde::de::Error::duplicate_field("glyph")); + } + glyph = Some(map.next_value::()?); + } + "color" => { + if color.is_some() { + return Err(serde::de::Error::duplicate_field("color")); + } + color = Some(map.next_value::()?); + } + _ => return Err(serde::de::Error::unknown_field(&key, &["glyph", "color"])), + } + } + + let glyph = glyph.ok_or_else(|| serde::de::Error::missing_field("glyph"))?; + + let color = if let Some(hex) = color { + let color = Color::from_hex(&hex).ok_or_else(|| { + serde::de::Error::custom(format!("`{hex} is not a valid color code`")) + })?; + Some(color) + } else { + None + }; + + Ok(Icon { glyph, color }) + } +} diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index e30a23381..89a58839c 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -11,6 +11,7 @@ pub mod expansion; pub mod graphics; pub mod gutter; pub mod handlers; +pub mod icons; pub mod info; pub mod input; pub mod keyboard;