mirror of
https://github.com/sxyazi/yazi.git
synced 2026-07-25 08:41:05 +00:00
feat: allow themes to add a custom style for file extensions
This commit is contained in:
parent
0ff4835f8d
commit
1c1845fc76
10 changed files with 325 additions and 39 deletions
7
.luarc.json
Normal file
7
.luarc.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"diagnostics.globals": [
|
||||
"ui",
|
||||
"THEME",
|
||||
"MANAGER"
|
||||
]
|
||||
}
|
||||
|
|
@ -49,13 +49,16 @@ impl Default for Theme {
|
|||
pub struct Manager {
|
||||
cwd: Style,
|
||||
|
||||
/// The style for files that have an extension
|
||||
pub extension: Option<Style>,
|
||||
|
||||
// Hovered
|
||||
hovered: Style,
|
||||
preview_hovered: Style,
|
||||
|
||||
// Find
|
||||
find_keyword: Style,
|
||||
find_position: Style,
|
||||
find_keyword: Style,
|
||||
pub find_position: Style,
|
||||
|
||||
// Marker
|
||||
marker_copied: Style,
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ use std::ops::Deref;
|
|||
|
||||
use mlua::{AnyUserData, IntoLua, Lua, UserDataFields, UserDataMethods};
|
||||
use yazi_config::THEME;
|
||||
use yazi_plugin::{bindings::{Cast, Cha, Icon, Range}, elements::Style, url::Url};
|
||||
use yazi_plugin::{bindings::{Cast, Cha, Icon}, elements::{Span, Style}, url::Url};
|
||||
use yazi_shared::MIME_DIR;
|
||||
|
||||
use self::highlightable_filename_builder::HighlightedFilenameBuilder;
|
||||
use super::{CtxRef, SCOPE};
|
||||
|
||||
mod highlightable_filename_builder;
|
||||
|
||||
pub(super) struct File {
|
||||
idx: usize,
|
||||
folder: *const yazi_core::folder::Folder,
|
||||
|
|
@ -123,17 +126,30 @@ impl File {
|
|||
});
|
||||
reg.add_method("highlights", |lua, me, ()| {
|
||||
let cx = lua.named_registry_value::<CtxRef>("cx")?;
|
||||
let Some(finder) = &cx.manager.active().finder else {
|
||||
return Ok(None);
|
||||
};
|
||||
if me.folder().cwd != me.tab().current.cwd {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(h) = me.name().and_then(|n| finder.filter.highlighted(n)) else {
|
||||
return Ok(None);
|
||||
|
||||
let stem = me.url.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let extension_with_leading_dot =
|
||||
me.url.extension().map(|ext| ".".to_string() + ext.to_string_lossy().as_ref());
|
||||
|
||||
let mut builder = HighlightedFilenameBuilder::new(stem, extension_with_leading_dot);
|
||||
if let Some(style) = THEME.manager.extension {
|
||||
if !me.is_dir() {
|
||||
builder.add_extension_highlight(style);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(h.into_iter().map(Range::from).collect::<Vec<_>>()))
|
||||
if let Some(finder) = &cx.manager.active().finder {
|
||||
if me.folder().cwd == me.tab().current.cwd {
|
||||
if let Some(highlighted) = me.name().and_then(|n| finder.filter.highlighted(n)) {
|
||||
highlighted
|
||||
.into_iter()
|
||||
.for_each(|range| builder.add_highlight(range, THEME.manager.find_position));
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let spans: Vec<Span> = builder.build_spans().into_iter().map(|span| span.into()).collect();
|
||||
Ok(Some(spans))
|
||||
});
|
||||
})?;
|
||||
|
||||
|
|
|
|||
261
yazi-fm/src/lives/file/highlightable_filename_builder.rs
Normal file
261
yazi-fm/src/lives/file/highlightable_filename_builder.rs
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
#![allow(unused_variables)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::{collections::HashMap, ops::Range};
|
||||
|
||||
use yazi_shared::theme::Style;
|
||||
|
||||
/// Represents the name and extension of a file or directory, that can
|
||||
/// optionally be highlighted (e.g. when searching, to highlight the matched
|
||||
/// characters), and converted to `ui.Span` elements for rendering. Consecutive
|
||||
/// highlights with the same style are merged into a single span.
|
||||
pub struct HighlightedFilenameBuilder {
|
||||
pub(crate) stem: String,
|
||||
/// The extension of the file, if any. Must have a leading dot.
|
||||
/// Example: `".txt"`
|
||||
pub(crate) extension: Option<String>,
|
||||
|
||||
/// Maps the index of a character in the full text to its highlight style.
|
||||
pub(crate) highlights: HashMap<usize, Style>,
|
||||
}
|
||||
|
||||
// NOTE: Since rust strings are utf-8 strings, the characters are not
|
||||
// necessarily one byte long. "".chars().count() will return the number of
|
||||
// characters in the string, not the number of bytes.
|
||||
impl HighlightedFilenameBuilder {
|
||||
pub fn new(stem: String, extension_with_leading_dot: Option<String>) -> Self {
|
||||
assert!(
|
||||
extension_with_leading_dot.is_none()
|
||||
|| extension_with_leading_dot.as_ref().unwrap().starts_with('.')
|
||||
);
|
||||
Self { stem, extension: extension_with_leading_dot, highlights: HashMap::new() }
|
||||
}
|
||||
|
||||
/// Adds a default color to the extension. It might be partially or fully
|
||||
/// overwritten by highlights that are added later.
|
||||
pub fn add_extension_highlight(&mut self, style: Style) {
|
||||
if let Some(extension) = &self.extension {
|
||||
let start = self.stem.len();
|
||||
let end = self.stem.len() + extension.len();
|
||||
|
||||
self.add_highlight(start..end, style);
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the indices specified by the given Range as having the given
|
||||
/// highlight style. Old highlights are overwritten.
|
||||
pub fn add_highlight(&mut self, range: Range<usize>, style: Style) {
|
||||
for i in range {
|
||||
self.highlights.insert(i, style);
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes the builder and produces styled spans of text for rendering. The
|
||||
/// stem and extension are separated into different spans.
|
||||
pub fn build_spans(&mut self) -> Vec<HighlightedSpan> {
|
||||
let (stem_highlights, extension_highlights) = {
|
||||
let mut highlights = Vec::new();
|
||||
|
||||
self.stem.chars().enumerate().for_each(|(index, character)| {
|
||||
let style = self.highlights.remove(&index);
|
||||
highlights.insert(index, HighlightedCharacter { character, style });
|
||||
});
|
||||
|
||||
if let Some(extension) = &self.extension {
|
||||
extension.chars().enumerate().for_each(|(index, character)| {
|
||||
let next = self.stem.chars().count() + index;
|
||||
let style = self.highlights.remove(&next);
|
||||
highlights
|
||||
.insert(self.stem.chars().count() + index, HighlightedCharacter { character, style });
|
||||
});
|
||||
}
|
||||
|
||||
let extension = highlights.split_off(self.stem.chars().count());
|
||||
let stem = highlights;
|
||||
(stem, extension)
|
||||
};
|
||||
|
||||
let mut spans = spanify(stem_highlights);
|
||||
spans.extend(spanify(extension_highlights));
|
||||
|
||||
spans
|
||||
}
|
||||
}
|
||||
|
||||
fn spanify(highlights: Vec<HighlightedCharacter>) -> Vec<HighlightedSpan> {
|
||||
let mut spans = Vec::new();
|
||||
let mut current_index = 0;
|
||||
|
||||
while let Some(this) = highlights.get(current_index) {
|
||||
let next_with_same_style =
|
||||
highlights[current_index + 1..].iter().take_while(|that| this.style == that.style);
|
||||
|
||||
let content =
|
||||
this.character.to_string() + &next_with_same_style.map(|c| c.character).collect::<String>();
|
||||
|
||||
current_index += content.len();
|
||||
let span = HighlightedSpan { content, style: this.style };
|
||||
spans.push(span);
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HighlightedSpan {
|
||||
content: String,
|
||||
style: Option<Style>,
|
||||
}
|
||||
|
||||
impl From<HighlightedSpan> for yazi_plugin::elements::Span {
|
||||
fn from(value: HighlightedSpan) -> Self {
|
||||
yazi_plugin::elements::Span(ratatui::text::Span {
|
||||
content: value.content.into(),
|
||||
style: value.style.map(Into::into).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HighlightedCharacter {
|
||||
character: char,
|
||||
style: Option<Style>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use yazi_shared::theme::StyleShadow;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn create_style(fgcolor: ratatui::style::Color) -> yazi_shared::theme::Style {
|
||||
Style::from(StyleShadow { fg: Some(yazi_shared::theme::Color(fgcolor)), ..Default::default() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_highlight_stem() {
|
||||
let mut hl = HighlightedFilenameBuilder::new("filename".to_string(), None);
|
||||
// when there is no extension, the whole name is the stem
|
||||
|
||||
let style = create_style(ratatui::style::Color::Blue);
|
||||
hl.add_highlight(0.."file".len(), style);
|
||||
let spans = hl.build_spans();
|
||||
assert_eq!(spans.len(), 2);
|
||||
|
||||
assert_eq!(spans[0].content, "file");
|
||||
assert_eq!(spans[0].style, Some(style));
|
||||
|
||||
assert_eq!(spans[1].content, "name");
|
||||
assert_eq!(spans[1].style, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_highlight_extension() {
|
||||
let mut hl = HighlightedFilenameBuilder::new("filename".to_string(), Some(".txt".to_string()));
|
||||
|
||||
let style = create_style(ratatui::style::Color::Blue);
|
||||
{
|
||||
// simulate the user highlighting the extension without the dot
|
||||
let stem_length = "filename.".chars().count();
|
||||
let full_length = stem_length + "txt".chars().count();
|
||||
hl.add_highlight(stem_length..full_length, style);
|
||||
}
|
||||
|
||||
let spans = hl.build_spans();
|
||||
assert_eq!(spans.len(), 3);
|
||||
|
||||
assert_eq!(spans[0].content, "filename");
|
||||
assert_eq!(spans[0].style, None);
|
||||
|
||||
assert_eq!(spans[1].content, ".");
|
||||
assert_eq!(spans[1].style, None);
|
||||
|
||||
assert_eq!(spans[2].content, "txt");
|
||||
assert_eq!(spans[2].style, Some(style));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_highlight_nothing() {
|
||||
// verify that the stem and extension are separate spans even if they are
|
||||
// not highlighted
|
||||
let mut hl = HighlightedFilenameBuilder::new("notes".to_string(), Some(".txt".to_string()));
|
||||
let spans = hl.build_spans();
|
||||
|
||||
assert_eq!(spans.len(), 2);
|
||||
|
||||
assert_eq!(spans[0].content, "notes");
|
||||
assert_eq!(spans[0].style, None);
|
||||
|
||||
assert_eq!(spans[1].content, ".txt");
|
||||
assert_eq!(spans[1].style, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_name() {
|
||||
// just to make sure it doesn't crash
|
||||
let mut hl = HighlightedFilenameBuilder::new("".to_string(), None);
|
||||
let spans = hl.build_spans();
|
||||
assert_eq!(spans.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_highlight_across_extension_boundary() {
|
||||
// for "filename.txt", if the user highlights "name.t"
|
||||
let mut hl = HighlightedFilenameBuilder::new("filename".to_string(), Some(".txt".to_string()));
|
||||
let style = create_style(ratatui::style::Color::Blue);
|
||||
|
||||
{
|
||||
let start = "file".chars().count();
|
||||
let end = "filename.t".chars().count();
|
||||
hl.add_highlight(start..end, style);
|
||||
}
|
||||
|
||||
let spans = hl.build_spans();
|
||||
assert_eq!(spans.len(), 4);
|
||||
|
||||
assert_eq!(spans[0].content, "file");
|
||||
assert_eq!(spans[0].style, None);
|
||||
|
||||
assert_eq!(spans[1].content, "name");
|
||||
assert_eq!(spans[1].style, Some(style));
|
||||
|
||||
assert_eq!(spans[2].content, ".t");
|
||||
assert_eq!(spans[2].style, Some(style));
|
||||
|
||||
assert_eq!(spans[3].content, "xt");
|
||||
assert_eq!(spans[3].style, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_extension_highlight() {
|
||||
let mut hl = HighlightedFilenameBuilder::new("filename".to_string(), Some(".txt".to_string()));
|
||||
let style = create_style(ratatui::style::Color::Blue);
|
||||
hl.add_extension_highlight(style);
|
||||
|
||||
let spans = hl.build_spans();
|
||||
assert_eq!(spans.len(), 2);
|
||||
|
||||
assert_eq!(spans[0].content, "filename");
|
||||
assert_eq!(spans[0].style, None);
|
||||
|
||||
assert_eq!(spans[1].content, ".txt");
|
||||
assert_eq!(spans[1].style, Some(style));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multibyte_characters() {
|
||||
// multi-byte characters must not cause panics even if the file name is very
|
||||
// complex. Test both the stem and the extension.
|
||||
let mut hl = HighlightedFilenameBuilder::new(
|
||||
"pokémon-listing-🤔-スゴイ-H̐͌̃ē̐̅l̐͑̚l͋͋́o̐̒̓,̍̑̀ ̓̈́̚w͊̀̕o̓͆͝r̎͝͝l͑̑̇d̎̐̕! ".to_string(),
|
||||
Some(".🤔-スH̐͌̃ē̐̅l̐͑̚txt".to_string()),
|
||||
);
|
||||
let style = create_style(ratatui::style::Color::Blue);
|
||||
hl.add_highlight(0.."poké".chars().count(), style);
|
||||
hl.add_extension_highlight(style);
|
||||
|
||||
let spans = hl.build_spans();
|
||||
|
||||
assert_eq!(spans.len(), 3);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,26 +16,8 @@ function File:prefix(file)
|
|||
return prefix == "" and {} or { ui.Span(prefix .. "/") }
|
||||
end
|
||||
|
||||
function File:highlights(file)
|
||||
local name = file.name:gsub("\r", "?", 1)
|
||||
local highlights = file:highlights()
|
||||
if not highlights or #highlights == 0 then
|
||||
return { ui.Span(name) }
|
||||
end
|
||||
|
||||
local spans, last = {}, 0
|
||||
for _, h in ipairs(highlights) do
|
||||
if h[1] > last then
|
||||
spans[#spans + 1] = ui.Span(name:sub(last + 1, h[1]))
|
||||
end
|
||||
spans[#spans + 1] = ui.Span(name:sub(h[1] + 1, h[2])):style(THEME.manager.find_keyword)
|
||||
last = h[2]
|
||||
end
|
||||
if last < #name then
|
||||
spans[#spans + 1] = ui.Span(name:sub(last + 1))
|
||||
end
|
||||
return spans
|
||||
end
|
||||
--- Returns the name for the file, optionally highlighted
|
||||
function File:highlights(file) return file:highlights() end
|
||||
|
||||
function File:found(file)
|
||||
if not file:is_hovered() then
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ use yazi_shared::theme::Color;
|
|||
|
||||
use super::Style;
|
||||
|
||||
#[derive(Clone, FromLua)]
|
||||
pub struct Span(pub(super) ratatui::text::Span<'static>);
|
||||
#[derive(Clone, FromLua, Debug)]
|
||||
pub struct Span(pub ratatui::text::Span<'static>);
|
||||
|
||||
impl Span {
|
||||
pub fn install(lua: &Lua, ui: &Table) -> mlua::Result<()> {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ use std::str::FromStr;
|
|||
use mlua::{AnyUserData, ExternalError, ExternalResult, Lua, Table, UserData, UserDataMethods, Value};
|
||||
use yazi_shared::theme::Color;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Style(pub(super) ratatui::style::Style);
|
||||
#[derive(Clone, Copy, Default, PartialEq, Debug)]
|
||||
pub struct Style(pub ratatui::style::Style);
|
||||
|
||||
impl Style {
|
||||
pub fn install(lua: &Lua, ui: &Table) -> mlua::Result<()> {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ impl Url {
|
|||
reg.add_method("stem", |lua, me, ()| {
|
||||
me.file_stem().map(|s| lua.create_string(s.as_encoded_bytes())).transpose()
|
||||
});
|
||||
reg.add_method("extension", |lua, me, ()| {
|
||||
me.extension().map(|s| lua.create_string(s.as_encoded_bytes())).transpose()
|
||||
});
|
||||
reg.add_method("join", |lua, me, other: UrlRef| Self::cast(lua, me.join(&*other)));
|
||||
reg.add_method("parent", |lua, me, ()| {
|
||||
me.parent_url().map(|u| Self::cast(lua, u)).transpose()
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ use std::str::FromStr;
|
|||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
|
||||
#[serde(try_from = "String")]
|
||||
pub struct Color(ratatui::style::Color);
|
||||
pub struct Color(pub ratatui::style::Color);
|
||||
|
||||
impl FromStr for Color {
|
||||
type Err = anyhow::Error;
|
||||
|
|
@ -25,6 +25,10 @@ impl From<Color> for ratatui::style::Color {
|
|||
fn from(value: Color) -> Self { value.0 }
|
||||
}
|
||||
|
||||
impl From<ratatui::style::Color> for Color {
|
||||
fn from(value: ratatui::style::Color) -> Self { Self(value) }
|
||||
}
|
||||
|
||||
impl Serialize for Color {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use serde::{ser::SerializeMap, Deserialize, Serialize, Serializer};
|
|||
|
||||
use super::Color;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
|
||||
#[serde(from = "StyleShadow")]
|
||||
pub struct Style {
|
||||
pub fg: Option<Color>,
|
||||
|
|
@ -36,6 +36,16 @@ impl From<Style> for ratatui::style::Style {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<ratatui::style::Style> for Style {
|
||||
fn from(value: ratatui::style::Style) -> Self {
|
||||
Self {
|
||||
fg: value.fg.map(Into::into),
|
||||
bg: value.bg.map(Into::into),
|
||||
modifier: value.add_modifier,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
pub struct StyleShadow {
|
||||
#[serde(default)]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue