From 2684efec5d28b48bf475914594e98a0b3d703712 Mon Sep 17 00:00:00 2001 From: sxyazi Date: Mon, 23 Mar 2026 15:51:27 +0800 Subject: [PATCH] perf: avoid unnecessary allocations in code highlighting --- .github/workflows/cachix.yml | 2 +- CHANGELOG.md | 2 + Cargo.lock | 11 +- cspell.json | 2 +- yazi-binding/src/elements/line.rs | 6 + yazi-fs/Cargo.toml | 2 +- yazi-plugin/preset/plugins/json.lua | 17 +- yazi-plugin/src/elements/elements.rs | 46 ++-- yazi-plugin/src/external/highlighter.rs | 314 ++++++++++++------------ yazi-plugin/src/utils/preview.rs | 2 +- yazi-shim/Cargo.toml | 5 + yazi-shim/src/ratatui/builder.rs | 58 +++++ yazi-shim/src/ratatui/line.rs | 83 +++++++ yazi-shim/src/ratatui/mod.rs | 2 +- yazi-shim/src/ratatui/paragraph.rs | 52 ---- yazi-shim/src/ratatui/span.rs | 80 ++++++ yazi-shim/src/ratatui/wrapper.rs | 193 +++++++++++++++ 17 files changed, 638 insertions(+), 239 deletions(-) create mode 100644 yazi-shim/src/ratatui/builder.rs create mode 100644 yazi-shim/src/ratatui/line.rs delete mode 100644 yazi-shim/src/ratatui/paragraph.rs create mode 100644 yazi-shim/src/ratatui/span.rs create mode 100644 yazi-shim/src/ratatui/wrapper.rs diff --git a/.github/workflows/cachix.yml b/.github/workflows/cachix.yml index 193cbf3d..686a1971 100644 --- a/.github/workflows/cachix.yml +++ b/.github/workflows/cachix.yml @@ -21,7 +21,7 @@ jobs: uses: cachix/install-nix-action@v31 - name: Authenticate with Cachix - uses: cachix/cachix-action@v16 + uses: cachix/cachix-action@v17 with: name: yazi authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index a3ca6912..9c3ea546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/): - Reduce memory allocations by using Lua 5.5 external strings ([#3634]) - Reuse previewed and spotted widgets when possible ([#3765]) +- Avoid unnecessary allocations in code highlighting ([#3804]) ## [v26.1.22] @@ -1690,3 +1691,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/): [#3780]: https://github.com/sxyazi/yazi/pull/3780 [#3781]: https://github.com/sxyazi/yazi/pull/3781 [#3792]: https://github.com/sxyazi/yazi/pull/3792 +[#3804]: https://github.com/sxyazi/yazi/pull/3804 diff --git a/Cargo.lock b/Cargo.lock index e5210dcf..91dec3d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,9 +171,9 @@ checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] name = "arc-swap" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" dependencies = [ "rustversion", ] @@ -6128,9 +6128,12 @@ dependencies = [ name = "yazi-shim" version = "26.2.2" dependencies = [ + "ansi-to-tui", "crossterm 0.29.0", "ratatui", "twox-hash", + "unicode-segmentation", + "unicode-width", "yazi-macro", ] @@ -6289,9 +6292,9 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.5.13" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c" +checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6" dependencies = [ "zune-core", ] diff --git a/cspell.json b/cspell.json index f1b5f8f0..098cad59 100644 --- a/cspell.json +++ b/cspell.json @@ -1 +1 @@ -{"version":"0.2","flagWords":[],"words":["Punct","KEYMAP","splitn","crossterm","YAZI","peekable","ratatui","syntect","pbpaste","pbcopy","oneshot","Posix","Lsar","XADDOS","zoxide","cands","Deque","precache","imageops","IFBLK","IFCHR","IFDIR","IFIFO","IFLNK","IFMT","IFSOCK","IRGRP","IROTH","IRUSR","ISGID","ISUID","ISVTX","IWGRP","IWOTH","IWUSR","IXGRP","IXOTH","IXUSR","libc","winsize","TIOCGWINSZ","xpixel","ypixel","ioerr","appender","Catppuccin","macchiato","gitmodules","Dotfiles","bashprofile","vimrc","flac","webp","exiftool","mediainfo","ripgrep","indexmap","indexmap","unwatch","canonicalize","serde","fsevent","Ueberzug","iterm","wezterm","sixel","chafa","ueberzugpp","Konsole","Überzug","pkgs","pdftoppm","poppler","singlefile","jpegopt","EXIF","rustfmt","mktemp","nanos","xclip","xsel","natord","Mintty","nixos","nixpkgs","SIGTSTP","SIGCONT","SIGCONT","mlua","nonstatic","userdata","metatable","natsort","backstack","luajit","Succ","Succ","cand","fileencoding","foldmethod","lightgreen","darkgray","lightred","lightyellow","lightcyan","nushell","msvc","aarch","linemode","sxyazi","rsplit","ZELLIJ","bitflags","bitflags","USERPROFILE","Neovim","vergen","gitcl","Renderable","preloaders","prec","Upserting","prio","Ghostty","Catmull","Lanczos","cmds","unyank","scrolloff","headsup","unsub","uzers","scopeguard","SPDLOG","globset","filetime","magick","magick","prefetcher","Prework","prefetchers","PREWORKERS","conds","translit","rxvt","Urxvt","realpath","realname","REPARSE","hardlink","hardlinking","nlink","nlink","linemodes","SIGSTOP","sevenzip","rsplitn","replacen","DECSET","DECRQM","repeek","cwds","tcsi","Hyprland","Wayfire","SWAYSOCK","btime","nsec","codegen","gethostname","fchmod","fdfind","Rustc","rustc","ffprobe","vframes","luma","obase","outln","errln","tmtheme","twox","cfgs","fstype","objc","rdev","runloop","exfat","rclone","DECRQSS","DECSCUSR","libvterm","Uninit","lockin","rposition","resvg","foldhash","tilded","futs","chdir","hashbrown","JEMALLOC","RUSTFLAGS","RDONLY","GETPATH","fcntl","casefold","inodes","Splatable","casefied","thiserror","memchr","memmem","russh","deadpool","keepalive","nodelay","publickey","deadpool","initing","treelize","TOCTOU","fellback","watchee"],"language":"en"} \ No newline at end of file +{"language":"en","words":["Punct","KEYMAP","splitn","crossterm","YAZI","peekable","ratatui","syntect","pbpaste","pbcopy","oneshot","Posix","Lsar","XADDOS","zoxide","cands","Deque","precache","imageops","IFBLK","IFCHR","IFDIR","IFIFO","IFLNK","IFMT","IFSOCK","IRGRP","IROTH","IRUSR","ISGID","ISUID","ISVTX","IWGRP","IWOTH","IWUSR","IXGRP","IXOTH","IXUSR","libc","winsize","TIOCGWINSZ","xpixel","ypixel","ioerr","appender","Catppuccin","macchiato","gitmodules","Dotfiles","bashprofile","vimrc","flac","webp","exiftool","mediainfo","ripgrep","indexmap","indexmap","unwatch","canonicalize","serde","fsevent","Ueberzug","iterm","wezterm","sixel","chafa","ueberzugpp","Konsole","Überzug","pkgs","pdftoppm","poppler","singlefile","jpegopt","EXIF","rustfmt","mktemp","nanos","xclip","xsel","natord","Mintty","nixos","nixpkgs","SIGTSTP","SIGCONT","SIGCONT","mlua","nonstatic","userdata","metatable","natsort","backstack","luajit","Succ","Succ","cand","fileencoding","foldmethod","lightgreen","darkgray","lightred","lightyellow","lightcyan","nushell","msvc","aarch","linemode","sxyazi","rsplit","ZELLIJ","bitflags","bitflags","USERPROFILE","Neovim","vergen","gitcl","Renderable","preloaders","prec","Upserting","prio","Ghostty","Catmull","Lanczos","cmds","unyank","scrolloff","headsup","unsub","uzers","scopeguard","SPDLOG","globset","filetime","magick","magick","prefetcher","Prework","prefetchers","PREWORKERS","conds","translit","rxvt","Urxvt","realpath","realname","REPARSE","hardlink","hardlinking","nlink","nlink","linemodes","SIGSTOP","sevenzip","rsplitn","replacen","DECSET","DECRQM","repeek","cwds","tcsi","Hyprland","Wayfire","SWAYSOCK","btime","nsec","codegen","gethostname","fchmod","fdfind","Rustc","rustc","ffprobe","vframes","luma","obase","outln","errln","tmtheme","twox","cfgs","fstype","objc","rdev","runloop","exfat","rclone","DECRQSS","DECSCUSR","libvterm","Uninit","lockin","rposition","resvg","foldhash","tilded","futs","chdir","hashbrown","JEMALLOC","RUSTFLAGS","RDONLY","GETPATH","fcntl","casefold","inodes","Splatable","casefied","thiserror","memchr","memmem","russh","deadpool","keepalive","nodelay","publickey","deadpool","initing","treelize","TOCTOU","fellback","watchee","Textlike"],"flagWords":[],"version":"0.2"} \ No newline at end of file diff --git a/yazi-binding/src/elements/line.rs b/yazi-binding/src/elements/line.rs index bd675daf..8d23a4a2 100644 --- a/yazi-binding/src/elements/line.rs +++ b/yazi-binding/src/elements/line.rs @@ -51,6 +51,12 @@ impl Line { } } +impl From> for Line { + fn from(value: ratatui::text::Line<'static>) -> Self { + Self { inner: value, ..Default::default() } + } +} + impl TryFrom for Line { type Error = mlua::Error; diff --git a/yazi-fs/Cargo.toml b/yazi-fs/Cargo.toml index 276af823..3457c881 100644 --- a/yazi-fs/Cargo.toml +++ b/yazi-fs/Cargo.toml @@ -20,7 +20,7 @@ yazi-shim = { path = "../yazi-shim", version = "26.2.2" } # External dependencies anyhow = { workspace = true } -arc-swap = "1.8.2" +arc-swap = "1.9.0" bitflags = { workspace = true } dirs = { workspace = true } either = { workspace = true } diff --git a/yazi-plugin/preset/plugins/json.lua b/yazi-plugin/preset/plugins/json.lua index 3c52d410..159bfc65 100644 --- a/yazi-plugin/preset/plugins/json.lua +++ b/yazi-plugin/preset/plugins/json.lua @@ -11,9 +11,9 @@ function M:peek(job) return require("code"):peek(job) end - local wrap = rt.preview.wrap + local opt = { ansi = true, tab_size = rt.preview.tab_size, wrap = rt.preview.wrap, width = job.area.w } local limit = job.area.h - local i, lines = 0, "" + local i, lines = 0, {} repeat local next, event = child:read_line() if event == 1 then @@ -22,9 +22,13 @@ function M:peek(job) break end - i = i + ui.height(next, { width = job.area.w, ansi = true, wrap = wrap }) - if i > job.skip then - lines = lines .. next + local wrapped = ui.lines(next, opt) + local from = math.max(1, job.skip - i + 1) + local to = math.min(#wrapped, job.skip + limit - i) + + i = i + #wrapped + for j = from, to do + lines[#lines + 1] = wrapped[j] end until i >= job.skip + limit @@ -32,8 +36,7 @@ function M:peek(job) if job.skip > 0 and i < job.skip + limit then ya.emit("peek", { math.max(0, i - limit), only_if = job.file.url, upper_bound = true }) else - lines = lines:gsub("\t", string.rep(" ", rt.preview.tab_size)) - ya.preview_widget(job, ui.Text.parse(lines):area(job.area):wrap(wrap)) + ya.preview_widget(job, ui.Text(lines):area(job.area)) end end diff --git a/yazi-plugin/src/elements/elements.rs b/yazi-plugin/src/elements/elements.rs index a379ce69..9cc35701 100644 --- a/yazi-plugin/src/elements/elements.rs +++ b/yazi-plugin/src/elements/elements.rs @@ -1,22 +1,21 @@ -use std::borrow::Cow; +use std::{borrow::Cow, iter}; -use ansi_to_tui::IntoText; use mlua::{AnyUserData, ExternalError, ExternalResult, IntoLua, Lua, ObjectLike, Table, Value}; use tracing::error; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use yazi_binding::{Composer, ComposerGet, ComposerSet, Permit, PermitRef, elements::{Line, Rect, Span, Wrap}, runtime}; -use yazi_config::{LAYOUT, YAZI}; +use yazi_config::LAYOUT; use yazi_proxy::AppProxy; use yazi_shared::replace_to_printable; -use yazi_shim::ratatui::line_count; +use yazi_shim::ratatui::LineIterBuilder; use yazi_term::YIELD_TO_SUBPROCESS; pub fn compose() -> Composer { fn get(lua: &Lua, key: &[u8]) -> mlua::Result { match key { b"area" => area(lua)?, - b"height" => height(lua)?, b"hide" => hide(lua)?, + b"lines" => lines(lua)?, b"printable" => printable(lua)?, b"redraw" => redraw(lua)?, b"render" => render(lua)?, @@ -46,21 +45,6 @@ pub(super) fn area(lua: &Lua) -> mlua::Result { f.into_lua(lua) } -pub(super) fn height(lua: &Lua) -> mlua::Result { - let f = lua.create_function(|_, (s, opts): (mlua::String, Table)| { - let width = opts.raw_get("width")?; - let wrap: Wrap = opts.raw_get("wrap")?; - - Ok(if opts.raw_get("ansi")? { - line_count(s.to_string_lossy().to_text().into_lua_err()?, width, YAZI.preview.indent(), wrap) - } else { - line_count(s.to_string_lossy(), width, YAZI.preview.indent(), wrap) - }) - })?; - - f.into_lua(lua) -} - pub(super) fn hide(lua: &Lua) -> mlua::Result { let f = lua.create_async_function(|lua, ()| async move { if runtime!(lua)?.blocking { @@ -81,6 +65,28 @@ pub(super) fn hide(lua: &Lua) -> mlua::Result { f.into_lua(lua) } +pub(super) fn lines(lua: &Lua) -> mlua::Result { + let f = lua.create_function(|lua, (s, opts): (mlua::String, Table)| { + let b = s.as_bytes(); + let s = String::from_utf8_lossy(&b); + + let mut it = LineIterBuilder::default() + .tab_size(opts.raw_get("tab_size")?) + .width(opts.raw_get("width")?) + .wrap(*opts.raw_get::("wrap")?) + .ansi(opts.raw_get("ansi")?) + .build(&s) + .into_lua_err()?; + + lua.create_sequence_from(iter::from_fn(|| { + let (spans, align) = it.next()?; + Some(Line::from(spans.into_static_line().alignment(align))) + })) + })?; + + f.into_lua(lua) +} + pub(super) fn printable(lua: &Lua) -> mlua::Result { let f = lua.create_function(|lua, s: mlua::String| { Ok(match replace_to_printable(&s.as_bytes(), false, 1, true) { diff --git a/yazi-plugin/src/external/highlighter.rs b/yazi-plugin/src/external/highlighter.rs index 29206ffb..1d265bb8 100644 --- a/yazi-plugin/src/external/highlighter.rs +++ b/yazi-plugin/src/external/highlighter.rs @@ -1,30 +1,147 @@ -use std::{io::Cursor, path::{Path, PathBuf}, sync::OnceLock}; +use std::{io::{BufRead, BufReader, Cursor, Seek}, path::PathBuf, sync::OnceLock}; -use anyhow::{Result, anyhow}; +use anyhow::{Result, bail}; use ratatui::{layout::Size, text::{Line, Span, Text}}; use syntect::{LoadingError, dumps, easy::HighlightLines, highlighting::{self, Theme, ThemeSet}, parsing::{SyntaxReference, SyntaxSet}}; -use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader}; -use yazi_config::{THEME, YAZI, preview::PreviewWrap}; -use yazi_fs::provider::{Provider, local::Local}; -use yazi_shared::{Ids, errors::PeekError, push_printable_char}; -use yazi_shim::ratatui::line_count; +use yazi_config::{THEME, YAZI}; +use yazi_shared::{Id, Ids, errors::PeekError, replace_to_printable}; +use yazi_shim::ratatui::LineIter; static INCR: Ids = Ids::new(); static SYNTECT: OnceLock<(Theme, SyntaxSet)> = OnceLock::new(); pub struct Highlighter { - path: PathBuf, + path: PathBuf, + reader: BufReader, + + skip: usize, + size: Size, + ticket: Id, + + theme: &'static Theme, + syntaxes: &'static SyntaxSet, + inner: Option>, + syntax: Option<&'static SyntaxReference>, } impl Highlighter { - pub fn new

(path: P) -> Self + pub async fn oneshot

(path: P, skip: usize, size: Size) -> Result, PeekError> where P: Into, { - Self { path: path.into() } + let path = path.into(); + tokio::task::spawn_blocking(move || Self::make(path, skip, size)?.highlight()).await? } - pub fn init() -> (&'static Theme, &'static SyntaxSet) { + fn make

(path: P, skip: usize, size: Size) -> Result + where + P: Into, + { + let path = path.into(); + let (theme, syntaxes) = Self::load(); + + Ok(Self { + reader: BufReader::new(std::fs::File::open(&path)?), + path, + + skip, + size, + ticket: INCR.current(), + + theme, + syntaxes, + inner: None, + syntax: None, + }) + } + + pub fn abort() { INCR.next(); } + + fn highlight(mut self) -> Result, PeekError> { + self.load_syntax()?; + let mut plain = self.syntax.is_none(); + + let mut i = 0; + let mut buf = vec![]; + let mut lines = Vec::with_capacity(self.size.height as usize); + let mut inspected = 0u16; + while self.reader.read_until(b'\n', &mut buf).is_ok_and(|n| n > 0) { + if Self::is_binary(&buf, &mut inspected) { + return Err("Binary file".into()); + } + + let remaining = Self::normalize_control_chars(&mut buf); + if remaining || buf.len() > 5000 { + plain = true; + } + + self.ensure_not_cancelled()?; + if plain && !self.process_plain(&buf, &mut i, &mut lines)? { + break; + } else if !plain && !self.process_hyper(&buf, &mut i, &mut lines)? { + break; + } + buf.clear(); + } + + if self.skip > 0 && i < self.skip + self.size.height as usize { + return Err(PeekError::Exceed(i.saturating_sub(self.size.height as _))); + } + + Ok(Text::from(lines)) + } + + fn process_plain(&mut self, buf: &[u8], i: &mut usize, lines: &mut Vec) -> Result { + let b = replace_to_printable(buf, true, YAZI.preview.tab_size, false); + let s = String::from_utf8_lossy(&b); + + let mut it = LineIter::source(&s, YAZI.preview.tab_size); + if let Some(wrap) = YAZI.preview.wrap.into() { + it = it.wrapped(wrap, self.size.width); + } + + while let Some((spans, _)) = it.next() { + *i += 1; + if *i > self.skip + self.size.height as usize { + return Ok(false); + } else if *i > self.skip { + lines.push(spans.into_static_line()); + } + self.ensure_not_cancelled()?; + } + Ok(true) + } + + fn process_hyper(&mut self, buf: &[u8], i: &mut usize, lines: &mut Vec) -> Result { + let Some(syntax) = self.syntax else { bail!("No syntax") }; + let h = self.inner.get_or_insert_with(|| HighlightLines::new(syntax, self.theme)); + + let s = String::from_utf8_lossy(buf); + let line = Self::to_line_widget(h.highlight_line(&s, self.syntaxes)?); + + let mut it = LineIter::parsed(vec![line], YAZI.preview.tab_size); + if let Some(wrap) = YAZI.preview.wrap.into() { + it = it.wrapped(wrap, self.size.width); + } + + while let Some((spans, _)) = it.next() { + *i += 1; + if *i > self.skip + self.size.height as usize { + return Ok(false); + } else if *i > self.skip { + lines.push(spans.into_static_line()); + } + self.ensure_not_cancelled()?; + } + Ok(true) + } + + #[inline] + fn ensure_not_cancelled(&self) -> Result<(), PeekError> { + if self.ticket != INCR.current() { Err("Highlighting cancelled".into()) } else { Ok(()) } + } + + fn load() -> (&'static Theme, &'static SyntaxSet) { let f = || { let theme = std::fs::File::open(&THEME.mgr.syntect_theme) .map_err(LoadingError::Io) @@ -40,114 +157,24 @@ impl Highlighter { (theme, syntaxes) } - pub fn abort() { INCR.next(); } - - pub async fn highlight(&self, skip: usize, size: Size) -> Result, PeekError> { - let indent = YAZI.preview.indent(); - let mut reader = BufReader::new(Local::regular(&self.path).open().await?); - - let syntax = Self::find_syntax(&self.path, &mut reader).await; - let mut plain = syntax.is_err(); - - let mut before = Vec::with_capacity(if plain { 0 } else { skip }); - let mut after = Vec::with_capacity(size.height as _); - - let mut i = 0; - let mut buf = vec![]; - let mut inspected = 0u16; - while reader.read_until(b'\n', &mut buf).await.is_ok_and(|n| n > 0) { - if Self::is_binary(&buf, &mut inspected) { - return Err("Binary file".into()); - } - - let remaining = Self::normalize_control_chars(&mut buf); - if !plain && (remaining || buf.len() > 5000) { - plain = true; - before.clear(); - } - - i += if i >= skip { - after.push(String::from_utf8_lossy(&buf).into_owned()); - line_count(&**after.last().unwrap(), size.width, &indent, YAZI.preview.wrap) - } else if !plain { - before.push(String::from_utf8_lossy(&buf).into_owned()); - line_count(&**before.last().unwrap(), size.width, &indent, YAZI.preview.wrap) - } else if YAZI.preview.wrap != PreviewWrap::No { - line_count(String::from_utf8_lossy(&buf), size.width, &indent, YAZI.preview.wrap) - } else { - 1 - }; - - buf.clear(); - if i > skip + size.height as usize { - break; - } + fn load_syntax(&mut self) -> Result<()> { + let name = self.path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(); + if let Some(s) = self.syntaxes.find_syntax_by_extension(&name) { + self.syntax = Some(s); + return Ok(()); } - if skip > 0 && i < skip + size.height as usize { - return Err(PeekError::Exceed(i.saturating_sub(size.height as _))); - } - - Ok(if plain { - Text::from(Self::merge_highlight_lines(&after, YAZI.preview.tab_size)) - } else { - Self::highlight_with(before, after, syntax.unwrap()).await? - }) - } - - async fn highlight_with( - before: Vec, - after: Vec, - syntax: &'static SyntaxReference, - ) -> Result, PeekError> { - let ticket = INCR.current(); - - tokio::task::spawn_blocking(move || { - let (theme, syntaxes) = Self::init(); - let mut h = HighlightLines::new(syntax, theme); - - for line in before { - if ticket != INCR.current() { - return Err("Highlighting cancelled".into()); - } - h.highlight_line(&line, syntaxes).map_err(|e| anyhow!(e))?; - } - - let indent = YAZI.preview.indent(); - let mut lines = Vec::with_capacity(after.len()); - for line in after { - if ticket != INCR.current() { - return Err("Highlighting cancelled".into()); - } - - let regions = h.highlight_line(&line, syntaxes).map_err(|e| anyhow!(e))?; - lines.push(Self::to_line_widget(regions, &indent)); - } - - Ok(Text::from(lines)) - }) - .await? - } - - async fn find_syntax( - path: &Path, - reader: &mut BufReader, - ) -> Result<&'static SyntaxReference> { - let (_, syntaxes) = Self::init(); - let name = path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(); - if let Some(s) = syntaxes.find_syntax_by_extension(&name) { - return Ok(s); - } - - let ext = path.extension().map(|e| e.to_string_lossy()).unwrap_or_default(); - if let Some(s) = syntaxes.find_syntax_by_extension(&ext) { - return Ok(s); + let ext = self.path.extension().map(|e| e.to_string_lossy()).unwrap_or_default(); + if let Some(s) = self.syntaxes.find_syntax_by_extension(&ext) { + self.syntax = Some(s); + return Ok(()); } let mut line = String::new(); - reader.read_line(&mut line).await?; - reader.rewind().await?; - syntaxes.find_syntax_by_first_line(&line).ok_or_else(|| anyhow!("No syntax found")) + self.reader.read_line(&mut line)?; + self.reader.rewind()?; + self.syntax = self.syntaxes.find_syntax_by_first_line(&line); + Ok(()) } #[inline(always)] @@ -177,47 +204,32 @@ impl Highlighter { } remaining } - - fn merge_highlight_lines(s: &[String], tab_size: u8) -> String { - let mut buf = Vec::new(); - buf.reserve_exact(s.iter().map(|s| s.len()).sum::() | 15); - - for &b in s.iter().flat_map(|s| s.as_bytes()) { - push_printable_char(&mut buf, b, true, tab_size, false); - } - unsafe { String::from_utf8_unchecked(buf) } - } } impl Highlighter { - pub fn to_line_widget(regions: Vec<(highlighting::Style, &str)>, indent: &str) -> Line<'static> { - let spans: Vec<_> = regions - .into_iter() - .map(|(style, s)| { - let mut modifier = ratatui::style::Modifier::empty(); - if style.font_style.contains(highlighting::FontStyle::BOLD) { - modifier |= ratatui::style::Modifier::BOLD; - } - if style.font_style.contains(highlighting::FontStyle::ITALIC) { - modifier |= ratatui::style::Modifier::ITALIC; - } - if style.font_style.contains(highlighting::FontStyle::UNDERLINE) { - modifier |= ratatui::style::Modifier::UNDERLINED; - } + fn to_line_widget<'a>(regions: Vec<(highlighting::Style, &'a str)>) -> Line<'a> { + Line::from_iter(regions.into_iter().map(|(style, s)| { + let mut modifier = ratatui::style::Modifier::empty(); + if style.font_style.contains(highlighting::FontStyle::BOLD) { + modifier |= ratatui::style::Modifier::BOLD; + } + if style.font_style.contains(highlighting::FontStyle::ITALIC) { + modifier |= ratatui::style::Modifier::ITALIC; + } + if style.font_style.contains(highlighting::FontStyle::UNDERLINE) { + modifier |= ratatui::style::Modifier::UNDERLINED; + } - Span { - content: s.replace('\t', indent).into(), - style: ratatui::style::Style { - fg: Self::to_ansi_color(style.foreground), - // bg: Self::to_ansi_color(style.background), - add_modifier: modifier, - ..Default::default() - }, - } - }) - .collect(); - - Line::from(spans) + Span { + content: s.into(), + style: ratatui::style::Style { + fg: Self::to_ansi_color(style.foreground), + // bg: Self::to_ansi_color(style.background), + add_modifier: modifier, + ..Default::default() + }, + } + })) } // Copy from https://github.com/sharkdp/bat/blob/master/src/terminal.rs diff --git a/yazi-plugin/src/utils/preview.rs b/yazi-plugin/src/utils/preview.rs index 6e509f52..302588b1 100644 --- a/yazi-plugin/src/utils/preview.rs +++ b/yazi-plugin/src/utils/preview.rs @@ -16,7 +16,7 @@ impl Utils { let mut lock = PreviewLock::try_from(t)?; let path = lock.url.as_url().unified_path(); - let inner = match Highlighter::new(path).highlight(lock.skip, area.size()).await { + let inner = match Highlighter::oneshot(path, lock.skip, area.size()).await { Ok(text) => text, Err(e @ PeekError::Exceed(max)) => return (e.to_string(), max).into_lua_multi(&lua), Err(e @ PeekError::Unexpected(_)) => { diff --git a/yazi-shim/Cargo.toml b/yazi-shim/Cargo.toml index 2a13e4f3..3f343454 100644 --- a/yazi-shim/Cargo.toml +++ b/yazi-shim/Cargo.toml @@ -16,9 +16,14 @@ workspace = true yazi-macro = { path = "../yazi-macro", version = "26.2.2" } # External dependencies +ansi-to-tui = { workspace = true } crossterm = { workspace = true } ratatui = { workspace = true } twox-hash = { workspace = true } +unicode-width = { workspace = true } + +[dependencies.unicode-segmentation] +version = "1" [target.'cfg(target_os = "macos")'.dependencies] crossterm = { workspace = true, features = [ "use-dev-tty", "libc" ] } diff --git a/yazi-shim/src/ratatui/builder.rs b/yazi-shim/src/ratatui/builder.rs new file mode 100644 index 00000000..413217df --- /dev/null +++ b/yazi-shim/src/ratatui/builder.rs @@ -0,0 +1,58 @@ +use ansi_to_tui::IntoText; +use ratatui::{text::Text, widgets::Wrap}; + +use crate::ratatui::LineIter; + +pub struct LineIterBuilder { + tab_size: u8, + ansi: bool, + wrap: Option, + width: u16, +} + +impl Default for LineIterBuilder { + fn default() -> Self { Self { tab_size: 2, ansi: false, wrap: None, width: u16::MAX } } +} + +impl LineIterBuilder { + pub fn tab_size(mut self, size: u8) -> Self { + self.tab_size = size; + self + } + + pub fn ansi(mut self, ansi: bool) -> Self { + self.ansi = ansi; + self + } + + pub fn wrap(mut self, wrap: Option) -> Self { + self.wrap = wrap; + self + } + + pub fn width(mut self, width: u16) -> Self { + self.width = width; + self + } + + pub fn build<'text>(self, s: &'text str) -> Result, ansi_to_tui::Error> { + let line = if self.ansi { + LineIter::parsed(parse_ansi_text(s)?.lines, self.tab_size) + } else { + LineIter::source(s, self.tab_size) + }; + + Ok(match self.wrap { + Some(wrap) => line.wrapped(wrap, self.width), + None => line, + }) + } +} + +fn parse_ansi_text<'text>(s: &'text str) -> Result, ansi_to_tui::Error> { + // SAFETY: ansi_to_tui::to_text() returns slices into the input text data. + // The public API ties that borrow to the temporary method receiver instead of + // the original `&str`, so we widen it back to `'text`, which is the lifetime of + // the source string stored by `LineIter`. + unsafe { Ok(std::mem::transmute::, Text<'text>>(s.to_text()?)) } +} diff --git a/yazi-shim/src/ratatui/line.rs b/yazi-shim/src/ratatui/line.rs new file mode 100644 index 00000000..8e532a29 --- /dev/null +++ b/yazi-shim/src/ratatui/line.rs @@ -0,0 +1,83 @@ +use std::{boxed::Box, iter, mem, str::{self, Lines}, vec::IntoIter}; + +use ratatui::{layout::Alignment, text::Line, widgets::Wrap}; + +use super::wrapper::{LineComposer, WordWrapper}; +use crate::ratatui::SpanIter; + +type WrappedLines<'text> = Box, Alignment)> + 'text>; + +pub struct LineIter<'text> { + inner: LineIterInner<'text>, + tab_size: u8, +} + +enum LineIterInner<'text> { + Source { empty: bool, lines: Lines<'text> }, + Parsed(IntoIter>), + Wrapped(WordWrapper<'text, WrappedLines<'text>, SpanIter<'text, 'text>>), + Unlimited(WrappedLines<'text>), +} + +impl<'text> LineIter<'text> { + pub fn source(source: &'text str, tab_size: u8) -> Self { + Self { + inner: LineIterInner::Source { empty: source.is_empty(), lines: source.lines() }, + tab_size, + } + } + + pub fn parsed(mut text: Vec>, tab_size: u8) -> Self { + if text.is_empty() { + text.push(Line::from("")); + } + Self { inner: LineIterInner::Parsed(text.into_iter()), tab_size } + } + + pub fn wrapped(mut self, wrap: Wrap, width: u16) -> Self { + let lines = Box::new(iter::from_fn(move || self.next_owned())); + Self { + inner: if width == 0 { + LineIterInner::Unlimited(lines) + } else { + LineIterInner::Wrapped(WordWrapper::new(lines, width, wrap.trim)) + }, + tab_size: 0, + } + } + + pub fn next<'lend>(&'lend mut self) -> Option<(SpanIter<'lend, 'text>, Alignment)> { + match self.inner { + LineIterInner::Source { .. } | LineIterInner::Parsed(_) => self.next_owned(), + LineIterInner::Wrapped(ref mut wrapper) => { + let wrapped = wrapper.next_line()?; + Some((SpanIter::Wrapped(wrapped.graphemes.iter()), wrapped.alignment)) + } + LineIterInner::Unlimited(ref mut lines) => lines.next(), + } + } + + fn next_owned(&mut self) -> Option<(SpanIter<'text, 'text>, Alignment)> { + match &mut self.inner { + LineIterInner::Source { empty, lines } => { + let line = if mem::replace(empty, false) { + Some(Line::from("")) + } else { + lines.next().map(Line::from) + }?; + let alignment = line.alignment.unwrap_or(Alignment::Left); + + Some((SpanIter::new(line, self.tab_size), alignment)) + } + LineIterInner::Parsed(lines) => { + let line = lines.next()?; + let alignment = line.alignment.unwrap_or(Alignment::Left); + + Some((SpanIter::new(line, self.tab_size), alignment)) + } + LineIterInner::Wrapped(_) | LineIterInner::Unlimited(_) => { + unreachable!(); // This branch is handled by next() and should never call next_owned() + } + } + } +} diff --git a/yazi-shim/src/ratatui/mod.rs b/yazi-shim/src/ratatui/mod.rs index dc4b07ac..a617f0e3 100644 --- a/yazi-shim/src/ratatui/mod.rs +++ b/yazi-shim/src/ratatui/mod.rs @@ -1 +1 @@ -yazi_macro::mod_flat!(paragraph); +yazi_macro::mod_flat!(builder line span wrapper); diff --git a/yazi-shim/src/ratatui/paragraph.rs b/yazi-shim/src/ratatui/paragraph.rs deleted file mode 100644 index 8a66de49..00000000 --- a/yazi-shim/src/ratatui/paragraph.rs +++ /dev/null @@ -1,52 +0,0 @@ -use ratatui::{text::Text, widgets::{Paragraph, Wrap}}; - -pub fn line_count<'a, T, W, I>(text: T, width: u16, indent: I, wrap: W) -> usize -where - T: Into>, - I: AsRef, - W: Into>, -{ - line_count_impl(text.into(), width, indent.as_ref(), wrap.into()) -} - -fn line_count_impl(mut text: Text<'_>, mut width: u16, indent: &str, wrap: Option) -> usize { - width = width.max(1); - - let Some(wrap) = wrap else { - return Paragraph::new(text).line_count(width); - }; - - if indent.len() == 1 { - return Paragraph::new(text).wrap(wrap).line_count(width); - } - - let extra = indent.len().saturating_sub(1); - for line in &mut text.lines { - for span in &mut line.spans { - let mut out = None::; - let mut start = 0; - for (idx, b) in span.content.bytes().enumerate() { - if b != b'\t' { - continue; - } - - let out = out.get_or_insert_with(|| String::with_capacity(span.content.len() + extra)); - if start < idx { - out.push_str(unsafe { span.content.get_unchecked(start..idx) }); - } - - out.push_str(indent); - start = idx + 1; - } - - if let Some(mut out) = out { - if start < span.content.len() { - out.push_str(unsafe { span.content.get_unchecked(start..) }); - } - span.content = out.into(); - } - } - } - - Paragraph::new(text).wrap(wrap).line_count(width) -} diff --git a/yazi-shim/src/ratatui/span.rs b/yazi-shim/src/ratatui/span.rs new file mode 100644 index 00000000..7465b388 --- /dev/null +++ b/yazi-shim/src/ratatui/span.rs @@ -0,0 +1,80 @@ +use std::{borrow::Cow, slice::Iter, vec::IntoIter}; + +use ratatui::{style::Style, text::{Line, Span, StyledGrapheme}}; +use unicode_segmentation::{Graphemes, UnicodeSegmentation}; + +#[allow(private_interfaces)] +pub enum SpanIter<'lend, 'text> { + Line { + spans: IntoIter>, + line_style: Style, + current: Option>, + tab_size: u8, + pending_tabs: u8, + pending_style: Style, + }, + Wrapped(Iter<'lend, StyledGrapheme<'text>>), +} + +struct CurrentSpan<'text> { + style: Style, + graphemes: Graphemes<'text>, +} + +impl<'lend, 'text> SpanIter<'lend, 'text> { + pub(super) fn new(line: Line<'text>, tab_size: u8) -> Self { + Self::Line { + spans: line.spans.into_iter(), + line_style: line.style, + current: None, + tab_size, + pending_tabs: 0, + pending_style: Style::default(), + } + } + + pub fn into_static_line(self) -> Line<'static> { + Line::from_iter(self.map(|g| Span { style: g.style, content: g.symbol.to_owned().into() })) + } +} + +impl<'lend, 'text> Iterator for SpanIter<'lend, 'text> { + type Item = StyledGrapheme<'text>; + + fn next(&mut self) -> Option { + match self { + Self::Wrapped(inner) => inner.next().cloned(), + Self::Line { spans, line_style, current, tab_size, pending_tabs, pending_style } => loop { + if *pending_tabs > 0 { + *pending_tabs -= 1; + return Some(StyledGrapheme::new(" ", *pending_style)); + } + + if let Some(span) = current + && let Some(symbol) = span.graphemes.next() + { + if symbol == "\t" { + if *tab_size == 0 { + continue; + } + + *pending_tabs = tab_size.saturating_sub(1); + *pending_style = span.style; + return Some(StyledGrapheme::new(" ", span.style)); + } + + return Some(StyledGrapheme::new(symbol, span.style)); + } + + let span = spans.next()?; + let Cow::Borrowed(content) = span.content else { + unreachable!("SpanIter only stores borrowed text") + }; + *current = Some(CurrentSpan { + style: line_style.patch(span.style), + graphemes: content.graphemes(true), + }); + }, + } + } +} diff --git a/yazi-shim/src/ratatui/wrapper.rs b/yazi-shim/src/ratatui/wrapper.rs new file mode 100644 index 00000000..19fd0d22 --- /dev/null +++ b/yazi-shim/src/ratatui/wrapper.rs @@ -0,0 +1,193 @@ +use std::{collections::VecDeque, mem}; + +use ratatui::{layout::Alignment, text::StyledGrapheme}; +use unicode_width::UnicodeWidthStr; + +pub trait LineComposer<'a> { + fn next_line<'lend>(&'lend mut self) -> Option>; +} + +pub struct WrappedLine<'lend, 'text> { + pub graphemes: &'lend [StyledGrapheme<'text>], + pub width: u16, + pub alignment: Alignment, +} + +#[derive(Debug, Default, Clone)] +pub struct WordWrapper<'a, O, I> +where + O: Iterator, + I: Iterator>, +{ + input_lines: O, + max_line_width: u16, + wrapped_lines: VecDeque>>, + current_alignment: Alignment, + current_line: Vec>, + trim: bool, + + pending_word: Vec>, + pending_whitespace: VecDeque>, + pending_line_pool: Vec>>, +} + +impl<'a, O, I> WordWrapper<'a, O, I> +where + O: Iterator, + I: Iterator>, +{ + pub const fn new(lines: O, max_line_width: u16, trim: bool) -> Self { + Self { + input_lines: lines, + max_line_width, + wrapped_lines: VecDeque::new(), + current_alignment: Alignment::Left, + current_line: vec![], + trim, + + pending_word: Vec::new(), + pending_whitespace: VecDeque::new(), + pending_line_pool: Vec::new(), + } + } + + fn process_input(&mut self, line_symbols: impl IntoIterator>) { + let mut pending_line = self.pending_line_pool.pop().unwrap_or_default(); + let mut line_width = 0; + let mut word_width = 0; + let mut whitespace_width = 0; + let mut non_whitespace_previous = false; + + self.pending_word.clear(); + self.pending_whitespace.clear(); + pending_line.clear(); + + for grapheme in line_symbols { + let is_whitespace = grapheme.is_whitespace(); + let symbol_width = grapheme.symbol.width() as u16; + + if symbol_width > self.max_line_width { + continue; + } + + let word_found = non_whitespace_previous && is_whitespace; + let trimmed_overflow = + pending_line.is_empty() && self.trim && word_width + symbol_width > self.max_line_width; + let whitespace_overflow = pending_line.is_empty() + && self.trim + && whitespace_width + symbol_width > self.max_line_width; + let untrimmed_overflow = pending_line.is_empty() + && !self.trim + && word_width + whitespace_width + symbol_width > self.max_line_width; + + if word_found || trimmed_overflow || whitespace_overflow || untrimmed_overflow { + if !pending_line.is_empty() || !self.trim { + pending_line.extend(self.pending_whitespace.drain(..)); + line_width += whitespace_width; + } + + pending_line.append(&mut self.pending_word); + line_width += word_width; + + self.pending_whitespace.clear(); + whitespace_width = 0; + word_width = 0; + } + + let line_full = line_width >= self.max_line_width; + let pending_word_overflow = + symbol_width > 0 && line_width + whitespace_width + word_width >= self.max_line_width; + + if line_full || pending_word_overflow { + let mut remaining_width = u16::saturating_sub(self.max_line_width, line_width); + + self.wrapped_lines.push_back(mem::take(&mut pending_line)); + line_width = 0; + + while let Some(grapheme) = self.pending_whitespace.front() { + let width = grapheme.symbol.width() as u16; + + if width > remaining_width { + break; + } + + whitespace_width -= width; + remaining_width -= width; + self.pending_whitespace.pop_front(); + } + + if is_whitespace && self.pending_whitespace.is_empty() { + continue; + } + } + + if is_whitespace { + whitespace_width += symbol_width; + self.pending_whitespace.push_back(grapheme); + } else { + word_width += symbol_width; + self.pending_word.push(grapheme); + } + + non_whitespace_previous = !is_whitespace; + } + + if pending_line.is_empty() + && self.pending_word.is_empty() + && !self.pending_whitespace.is_empty() + && self.trim + { + self.wrapped_lines.push_back(vec![]); + } + if !pending_line.is_empty() || !self.trim { + pending_line.extend(self.pending_whitespace.drain(..)); + } + pending_line.append(&mut self.pending_word); + + #[expect(clippy::else_if_without_else)] + if !pending_line.is_empty() { + self.wrapped_lines.push_back(pending_line); + } else if pending_line.capacity() > 0 { + self.pending_line_pool.push(pending_line); + } + if self.wrapped_lines.is_empty() { + self.wrapped_lines.push_back(vec![]); + } + } + + fn replace_current_line(&mut self, line: Vec>) { + let cache = mem::replace(&mut self.current_line, line); + if cache.capacity() > 0 { + self.pending_line_pool.push(cache); + } + } +} + +impl<'a, O, I> LineComposer<'a> for WordWrapper<'a, O, I> +where + O: Iterator, + I: Iterator>, +{ + fn next_line<'lend>(&'lend mut self) -> Option> { + if self.max_line_width == 0 { + return None; + } + + loop { + if let Some(line) = self.wrapped_lines.pop_front() { + let line_width = line.iter().map(|grapheme| grapheme.symbol.width() as u16).sum(); + + self.replace_current_line(line); + return Some(WrappedLine { + graphemes: &self.current_line, + width: line_width, + alignment: self.current_alignment, + }); + } + + let (line_symbols, line_alignment) = self.input_lines.next()?; + self.current_alignment = line_alignment; + self.process_input(line_symbols); + } + } +}