fix: shift of auto-wrap points in both code and JSON previewers (#3739)

This commit is contained in:
三咲雅 misaki masa 2026-03-05 20:16:03 +08:00 committed by GitHub
parent 9648d8a43b
commit be91b4111c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 163 additions and 85 deletions

38
Cargo.lock generated
View file

@ -1402,9 +1402,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.9"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3"
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
dependencies = [
"serde",
"serde_core",
@ -1735,19 +1735,19 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.1"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
@ -3366,9 +3366,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.44"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@ -3379,6 +3379,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radium"
version = "0.7.0"
@ -4619,9 +4625,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.49.0"
version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
@ -4636,9 +4642,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.6.0"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
@ -4671,9 +4677,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.0.3+spec-1.1.0"
version = "1.0.4+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c"
checksum = "c94c3321114413476740df133f0d8862c61d87c8d26f04c6841e033c8c80db47"
dependencies = [
"indexmap 2.13.0",
"serde_core",
@ -4902,7 +4908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
dependencies = [
"atomic",
"getrandom 0.4.1",
"getrandom 0.4.2",
"js-sys",
"serde_core",
"wasm-bindgen",
@ -6066,6 +6072,7 @@ dependencies = [
name = "yazi-plugin"
version = "26.2.2"
dependencies = [
"ansi-to-tui",
"anyhow",
"crossterm 0.29.0",
"futures",
@ -6192,6 +6199,7 @@ name = "yazi-shim"
version = "26.2.2"
dependencies = [
"crossterm 0.29.0",
"ratatui",
"twox-hash",
"yazi-macro",
]

View file

@ -68,10 +68,10 @@ serde_json = "1.0.149"
serde_with = "3.17.0"
syntect = { version = "5.3.0", default-features = false, features = [ "parsing", "plist-load", "regex-onig" ] }
thiserror = "2.0.18"
tokio = { version = "1.49.0", features = [ "full" ] }
tokio = { version = "1.50.0", features = [ "full" ] }
tokio-stream = "0.1.18"
tokio-util = "0.7.18"
toml = { version = "1.0.3" }
toml = { version = "1.0.4" }
tracing = { version = "0.1.44", features = [ "max_level_debug", "release_max_level_debug" ] }
twox-hash = { version = "2.1.2", default-features = false, features = [ "std", "random", "xxhash3_128" ] }
typed-path = "0.12.3"

View file

@ -18,6 +18,10 @@ impl Wrap {
}
}
impl From<Wrap> for Option<ratatui::widgets::Wrap> {
fn from(value: Wrap) -> Self { value.0 }
}
impl From<ratatui::widgets::Wrap> for Wrap {
fn from(value: ratatui::widgets::Wrap) -> Self { Self(Some(value)) }
}
@ -52,3 +56,13 @@ impl FromLua for Wrap {
}))
}
}
impl IntoLua for Wrap {
fn into_lua(self, lua: &Lua) -> mlua::Result<Value> {
match self.0 {
None => 0.into_lua(lua),
Some(ratatui::widgets::Wrap { trim: false }) => 1.into_lua(lua),
Some(ratatui::widgets::Wrap { trim: true }) => 2.into_lua(lua),
}
}
}

View file

@ -18,4 +18,4 @@ proc-macro = true
[dependencies]
# External dependencies
syn = { version = "2.0.117", features = [ "full" ] }
quote = "1.0.44"
quote = "1.0.45"

View file

@ -1,4 +1,4 @@
use std::{borrow::Cow, path::PathBuf};
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
@ -9,9 +9,6 @@ use yazi_shared::{SStr, timestamp_us};
use super::PreviewWrap;
use crate::normalize_path;
#[rustfmt::skip]
const TABS: &[&str] = &["", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "];
#[derive(Debug, Deserialize, DeserializeOver2, Serialize)]
pub struct Preview {
pub wrap: PreviewWrap,
@ -34,10 +31,15 @@ impl Preview {
self.cache_dir.join(format!("{prefix}-{}", timestamp_us()))
}
pub fn indent(&self) -> SStr { Self::indent_with(self.tab_size as usize) }
pub fn indent(&self) -> SStr {
#[rustfmt::skip]
const TABS: &[&str] = &["", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "];
pub fn indent_with(n: usize) -> SStr {
if let Some(s) = TABS.get(n) { Cow::Borrowed(s) } else { Cow::Owned(" ".repeat(n)) }
if let Some(&s) = TABS.get(self.tab_size as usize) {
s.into()
} else {
" ".repeat(self.tab_size as usize).into()
}
}
}

View file

@ -6,3 +6,12 @@ pub enum PreviewWrap {
No,
Yes,
}
impl From<PreviewWrap> for Option<ratatui::widgets::Wrap> {
fn from(wrap: PreviewWrap) -> Self {
match wrap {
PreviewWrap::No => None,
PreviewWrap::Yes => Some(ratatui::widgets::Wrap { trim: false }),
}
}
}

View file

@ -34,6 +34,7 @@ yazi-vfs = { path = "../yazi-vfs", version = "26.2.2" }
yazi-widgets = { path = "../yazi-widgets", version = "26.2.2" }
# External dependencies
ansi-to-tui = { workspace = true }
anyhow = { workspace = true }
futures = { workspace = true }
hashbrown = { workspace = true }

View file

@ -11,6 +11,7 @@ function M:peek(job)
return require("code"):peek(job)
end
local wrap = rt.preview.wrap
local limit = job.area.h
local i, lines = 0, ""
repeat
@ -21,7 +22,7 @@ function M:peek(job)
break
end
i = i + 1
i = i + ui.height(next, { width = job.area.w, ansi = true, wrap = wrap })
if i > job.skip then
lines = lines .. next
end
@ -32,10 +33,7 @@ function M:peek(job)
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(rt.preview.wrap == "yes" and ui.Wrap.YES or ui.Wrap.NO)
)
ya.preview_widget(job, ui.Text.parse(lines):area(job.area):wrap(wrap))
end
end

View file

@ -1,18 +1,21 @@
use std::borrow::Cow;
use mlua::{AnyUserData, ExternalError, IntoLua, Lua, ObjectLike, Table, Value};
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}, runtime};
use yazi_config::LAYOUT;
use yazi_binding::{Composer, ComposerGet, ComposerSet, Permit, PermitRef, elements::{Line, Rect, Span, Wrap}, runtime};
use yazi_config::{LAYOUT, YAZI};
use yazi_proxy::AppProxy;
use yazi_shared::replace_to_printable;
use yazi_shim::ratatui::line_count;
use yazi_term::YIELD_TO_SUBPROCESS;
pub fn compose() -> Composer<ComposerGet, ComposerSet> {
fn get(lua: &Lua, key: &[u8]) -> mlua::Result<Value> {
match key {
b"area" => area(lua)?,
b"height" => height(lua)?,
b"hide" => hide(lua)?,
b"printable" => printable(lua)?,
b"redraw" => redraw(lua)?,
@ -43,6 +46,21 @@ pub(super) fn area(lua: &Lua) -> mlua::Result<Value> {
f.into_lua(lua)
}
pub(super) fn height(lua: &Lua) -> mlua::Result<Value> {
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<Value> {
let f = lua.create_async_function(|lua, ()| async move {
if runtime!(lua)?.blocking {

View file

@ -1,4 +1,4 @@
use std::{borrow::Cow, io::Cursor, mem, path::{Path, PathBuf}, sync::OnceLock};
use std::{io::Cursor, path::{Path, PathBuf}, sync::OnceLock};
use anyhow::{Result, anyhow};
use ratatui::{layout::Size, text::{Line, Span, Text}};
@ -7,6 +7,7 @@ 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;
static INCR: Ids = Ids::new();
static SYNTECT: OnceLock<(Theme, SyntaxSet)> = OnceLock::new();
@ -16,7 +17,6 @@ pub struct Highlighter {
}
impl Highlighter {
#[inline]
pub fn new<P>(path: P) -> Self
where
P: Into<PathBuf>,
@ -40,10 +40,10 @@ impl Highlighter {
(theme, syntaxes)
}
#[inline]
pub fn abort() { INCR.next(); }
pub async fn highlight(&self, skip: usize, size: Size) -> Result<Text<'static>, 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;
@ -60,26 +60,20 @@ impl Highlighter {
return Err("Binary file".into());
}
if !plain && (buf.len() > 5000 || Self::contains_control_chars(&buf)) {
let remaining = Self::normalize_control_chars(&mut buf);
if !plain && (remaining || buf.len() > 5000) {
plain = true;
drop(mem::take(&mut before));
}
if buf.ends_with(b"\r\n") {
buf.pop();
buf.pop();
buf.push(b'\n');
before.clear();
}
i += if i >= skip {
buf.iter_mut().for_each(Self::carriage_return_to_line_feed);
after.push(String::from_utf8_lossy(&buf).into_owned());
Self::line_height(&after[after.len() - 1], size.width)
line_count(&**after.last().unwrap(), size.width, &indent, YAZI.preview.wrap)
} else if !plain {
before.push(String::from_utf8_lossy(&buf).into_owned());
Self::line_height(&before[before.len() - 1], size.width)
} else if YAZI.preview.wrap == PreviewWrap::Yes {
Self::line_height(&String::from_utf8_lossy(&buf), size.width)
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
};
@ -166,42 +160,22 @@ impl Highlighter {
}
}
fn line_height(s: &str, width: u16) -> usize {
if YAZI.preview.wrap != PreviewWrap::Yes {
return 1;
fn normalize_control_chars(buf: &mut Vec<u8>) -> bool {
if buf.ends_with(b"\r\n") {
buf.pop();
buf.pop();
buf.push(b'\n');
}
let pad = YAZI
.preview
.tab_size
.checked_sub(1)
.map(|n| s.bytes().filter(|&b| b == b'\t').count() * n as usize)
.map(|n| yazi_config::preview::Preview::indent_with(n))
.unwrap_or_default();
let line = Line {
spans: vec![Span { content: pad, style: Default::default() }, Span {
content: Cow::Borrowed(s),
..Default::default()
}],
..Default::default()
};
ratatui::widgets::Paragraph::new(line)
.wrap(ratatui::widgets::Wrap { trim: false })
.line_count(width)
}
#[inline(always)]
fn contains_control_chars(buf: &[u8]) -> bool {
buf.iter().any(|&b| b.is_ascii_control() && !matches!(b, b'\t' | b'\n' | b'\r'))
}
#[inline(always)]
fn carriage_return_to_line_feed(b: &mut u8) {
if *b == b'\r' {
*b = b'\n';
let mut remaining = false;
for b in buf.iter_mut() {
if *b == b'\r' {
*b = b'\n';
} else {
remaining |= matches!(b, 0..=0x08 | 0x0B..=0x1F | 0x7F);
}
}
remaining
}
fn merge_highlight_lines(s: &[String], tab_size: u8) -> String {

View file

@ -1,5 +1,5 @@
use mlua::{IntoLua, Lua, LuaSerdeExt, Value};
use yazi_binding::{Composer, ComposerGet, ComposerSet, SER_OPT, Url};
use yazi_binding::{Composer, ComposerGet, ComposerSet, SER_OPT, Url, elements::Wrap};
use yazi_boot::ARGS;
use yazi_config::YAZI;
@ -78,7 +78,7 @@ fn preview() -> Composer<ComposerGet, ComposerSet> {
fn get(lua: &Lua, key: &[u8]) -> mlua::Result<Value> {
let p = &YAZI.preview;
match key {
b"wrap" => lua.to_value_with(&p.wrap, SER_OPT)?,
b"wrap" => Wrap::from(p.wrap).into_lua(lua)?,
b"tab_size" => p.tab_size.into_lua(lua)?,
b"max_width" => p.max_width.into_lua(lua)?,
b"max_height" => p.max_height.into_lua(lua)?,

View file

@ -17,6 +17,7 @@ yazi-macro = { path = "../yazi-macro", version = "26.2.2" }
# External dependencies
crossterm = { workspace = true }
ratatui = { workspace = true }
twox-hash = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]

View file

@ -1,3 +1,3 @@
yazi_macro::mod_pub!(crossterm);
yazi_macro::mod_pub!(crossterm ratatui);
yazi_macro::mod_flat!(twox);

View file

@ -0,0 +1 @@
yazi_macro::mod_flat!(paragraph);

View file

@ -0,0 +1,52 @@
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<Text<'a>>,
I: AsRef<str>,
W: Into<Option<Wrap>>,
{
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<Wrap>) -> 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::<String>;
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)
}