This commit is contained in:
Jed 2026-07-21 16:29:31 +10:00 committed by GitHub
commit 9bba3830f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 271 additions and 53 deletions

View file

@ -25,6 +25,8 @@ impl Preview {
impl UserData for Preview {
fn add_fields<F: UserDataFields<Self>>(fields: &mut F) {
fields.add_field_method_get("skip", |_, me| Ok(me.skip));
fields.add_field_method_get("search_idx", |_, me| Ok(me.search_idx));
fields.add_static_field("folder", |_, me| {
me.tab
.hovered_folder()

View file

@ -37,13 +37,13 @@ impl Actor for Peek {
succ!();
}
let preview = &mut cx.tab_mut().preview;
if let Some(skip) = form.skip {
let preview = &mut cx.tab_mut().preview;
if form.upper_bound {
preview.skip = preview.skip.min(skip);
} else {
preview.skip = skip;
}
preview.skip = skip;
}
if let Some(index) = form.search_idx {
preview.search_idx = Some(index);
}
if hovered.is_dir() {

View file

@ -0,0 +1,44 @@
use mlua::{ExternalError, FromLua, IntoLua, Lua, Result as LuaResult, Value};
const EXPECTED: &str = "expected a table containing a line and a length";
#[derive(Clone, Debug)]
pub struct HighlightPosition {
pub line: usize,
pub col: usize,
pub length: usize,
}
impl HighlightPosition {
pub fn compose(lua: &Lua) -> LuaResult<Value> {
let new = lua.create_function(|_, (line, col, length): (usize, usize, usize)| {
Ok(Self { line, col, length })
})?;
let tbl = lua.create_table_from([("new", new)])?;
tbl.into_lua(lua)
}
}
impl FromLua for HighlightPosition {
fn from_lua(value: Value, _: &Lua) -> mlua::Result<Self> {
match value {
Value::Table(tbl) => Ok(Self {
line: tbl.raw_get("line")?,
col: tbl.raw_get("col")?,
length: tbl.raw_get("length")?,
}),
_ => Err(EXPECTED.into_lua_err()),
}
}
}
impl IntoLua for HighlightPosition {
fn into_lua(self, lua: &Lua) -> LuaResult<Value> {
let tbl = lua.create_table()?;
tbl.set("line", self.line)?;
tbl.set("col", self.col)?;
tbl.set("length", self.length)?;
Ok(Value::Table(tbl))
}
}

View file

@ -1 +1 @@
yazi_macro::mod_flat!(align area bar border cell color constraint edge fill gauge layout line list pad rect row span spatial table text wrap);
yazi_macro::mod_flat!(align area bar border cell color constraint edge fill gauge highlight layout line list pad rect row span spatial table text wrap);

View file

@ -3,6 +3,7 @@ use std::{io::{BufRead, BufReader, Cursor, Seek}, path::PathBuf, sync::OnceLock}
use anyhow::{Result, anyhow, bail};
use ratatui_core::{layout::Size, text::{Line, Span, Text}};
use syntect::{LoadingError, dumps, easy::HighlightLines, highlighting::{self, Theme, ThemeSet}, parsing::{SyntaxReference, SyntaxSet}};
use yazi_binding::elements::HighlightPosition;
use yazi_config::{THEME, YAZI};
use yazi_runner::previewer::PeekError;
use yazi_shared::{id::{Id, Ids}, replace_to_printable};
@ -25,12 +26,17 @@ pub struct Highlighter {
}
impl Highlighter {
pub async fn oneshot<P>(path: P, skip: usize, size: Size) -> Result<Text<'static>, PeekError>
pub async fn oneshot<P>(
path: P,
skip: usize,
size: Size,
position: Option<HighlightPosition>,
) -> Result<Text<'static>, PeekError>
where
P: Into<PathBuf>,
{
let path = path.into();
tokio::task::spawn_blocking(move || Self::make(path, skip, size)?.highlight()).await?
tokio::task::spawn_blocking(move || Self::make(path, skip, size)?.highlight(position)).await?
}
fn make<P>(path: P, skip: usize, size: Size) -> Result<Self>
@ -59,7 +65,7 @@ impl Highlighter {
pub fn abort() { INCR.next(); }
fn highlight(mut self) -> Result<Text<'static>, PeekError> {
fn highlight(mut self, position: Option<HighlightPosition>) -> Result<Text<'static>, PeekError> {
self.load_syntax()?;
let mut plain = self.syntax.is_none();
@ -78,9 +84,9 @@ impl Highlighter {
}
self.ensure_not_cancelled()?;
if plain && !self.process_plain(&buf, &mut i, &mut lines)? {
if plain && !self.process_plain(&buf, &mut i, &position, &mut lines)? {
break;
} else if !plain && !self.process_hyper(&buf, &mut i, &mut lines)? {
} else if !plain && !self.process_hyper(&buf, &mut i, &position, &mut lines)? {
break;
}
buf.clear();
@ -89,11 +95,16 @@ impl Highlighter {
if self.skip > 0 && i < self.skip + self.size.height as usize {
return Err(PeekError::Exceeded(i.saturating_sub(self.size.height as _)));
}
Ok(Text::from(lines))
}
fn process_plain(&mut self, buf: &[u8], i: &mut usize, lines: &mut Vec<Line>) -> Result<bool> {
fn process_plain(
&mut self,
buf: &[u8],
i: &mut usize,
position: &Option<HighlightPosition>,
lines: &mut Vec<Line>,
) -> Result<bool> {
let b = replace_to_printable(buf, true, YAZI.preview.tab_size, false);
let s = String::from_utf8_lossy(&b);
@ -107,20 +118,31 @@ impl Highlighter {
if *i > self.skip + self.size.height as usize {
return Ok(false);
} else if *i > self.skip {
lines.push(spans.into_static_line());
let mut static_line = spans.into_static_line();
if let Some(pos) = position
&& pos.line == *i
{
static_line.style = ratatui_core::style::Style::new().bg(ratatui_core::style::Color::Red);
}
lines.push(static_line);
}
self.ensure_not_cancelled()?;
}
Ok(true)
}
fn process_hyper(&mut self, buf: &[u8], i: &mut usize, lines: &mut Vec<Line>) -> Result<bool> {
fn process_hyper(
&mut self,
buf: &[u8],
i: &mut usize,
position: &Option<HighlightPosition>,
lines: &mut Vec<Line>,
) -> Result<bool> {
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 line = [Self::to_line_widget(h.highlight_line(&s, self.syntaxes)?)];
let mut it = LineIter::parsed(&line, YAZI.preview.tab_size);
if let Some(wrap) = YAZI.preview.wrap.into() {
it = it.wrapped(wrap, self.size.width);
@ -131,10 +153,17 @@ impl Highlighter {
if *i > self.skip + self.size.height as usize {
return Ok(false);
} else if *i > self.skip {
lines.push(spans.into_static_line());
let mut static_line = spans.into_static_line();
if let Some(pos) = position
&& pos.line == *i
{
static_line.style = ratatui_core::style::Style::new().bg(ratatui_core::style::Color::Red);
}
lines.push(static_line);
}
self.ensure_not_cancelled()?;
}
Ok(true)
}

View file

@ -48,9 +48,10 @@ impl MgrProxy {
cha: job.file.cha,
mime: job.mime,
skip: job.skip,
area: area.into(),
data: vec![
skip: job.skip,
search_idx: job.search_idx,
area: area.into(),
data: vec![
Renderable::Clear(Default::default()).with_area(area),
Renderable::from(Error::custom(error)).with_area(area),
],

View file

@ -12,10 +12,11 @@ use yazi_vfs::{VfsEntries, VfsFilesOp};
use crate::{AppProxy, Highlighter, MgrProxy, tab::PreviewLock};
#[derive(Default)]
#[derive(Default, Debug)]
pub struct Preview {
pub lock: Option<PreviewLock>,
pub skip: usize,
pub lock: Option<PreviewLock>,
pub skip: usize,
pub search_idx: Option<usize>,
handle: Option<JoinHandle<()>>,
pub folder_lock: Option<UrlBuf>,
@ -35,7 +36,8 @@ impl Preview {
};
self.abort();
let job = PeekJob { previewer, file, mime, skip: self.skip };
let job = PeekJob { previewer, file, mime, skip: self.skip, search_idx: self.search_idx };
self.handle = Some(tokio::spawn(async move {
let mut rx = RUNNER.peek(&job).await;
@ -83,6 +85,7 @@ impl Preview {
}
pub fn reset(&mut self) {
self.search_idx = None;
self.abort();
ADAPTOR.image_hide().ok();
render!(self.lock.take().is_some())
@ -101,7 +104,8 @@ impl Preview {
}
pub fn same_lock(&self, file: &File, mime: &str) -> bool {
self.same_file(file, mime) && matches!(&self.lock, Some(l) if l.skip == self.skip)
self.same_file(file, mime)
&& matches!(&self.lock, Some(l) if l.skip == self.skip && l.search_idx == self.search_idx)
}
pub fn same_folder(&self, url: &UrlBuf) -> bool { self.folder_lock.as_ref() == Some(url) }

View file

@ -11,9 +11,10 @@ pub struct PreviewLock {
pub cha: Cha,
pub mime: Symbol<str>,
pub skip: usize,
pub area: Rect,
pub data: Vec<Renderable>,
pub skip: usize,
pub search_idx: Option<usize>,
pub area: Rect,
pub data: Vec<Renderable>,
}
impl_data_any!(PreviewLock);
@ -29,9 +30,10 @@ impl TryFrom<Table> for PreviewLock {
cha: f.cha,
mime: t.raw_get::<LuaString>("mime")?.to_str()?.intern(),
skip: t.raw_get("skip")?,
area: t.raw_get("area")?,
data: Default::default(),
skip: t.raw_get("skip")?,
search_idx: t.raw_get("search_idx")?,
area: t.raw_get("area")?,
data: Default::default(),
})
})
}

View file

@ -7,6 +7,7 @@ pub struct PeekForm {
pub force: bool,
pub only_if: Option<UrlBuf>,
pub upper_bound: bool,
pub search_idx: Option<usize>,
}
impl From<ActionCow> for PeekForm {
@ -16,6 +17,7 @@ impl From<ActionCow> for PeekForm {
force: a.bool("force"),
only_if: a.take("only-if").ok(),
upper_bound: a.bool("upper-bound"),
search_idx: a.take("search-idx").ok(),
}
}
}

View file

@ -1,26 +1,104 @@
local M = {}
local function get_search_occurrences(url)
if not url.is_search then
return
end
-- Shoud we encode ~ too?
local subject_len, occurrences = tostring(url):match("^search://([^~]+)~(.-)//")
local lines = {}
for occurrence in occurrences:gmatch("[^,]+") do
local line, col = occurrence:match("(%d+)-(%d+)")
table.insert(lines, { tonumber(line), tonumber(col) })
end
if #lines == 0 then
return
end
return tonumber(subject_len), lines
end
local function get_next_occurrence_idx(search_idx, direction, occurrences_len)
local index
if direction == "up" then
index = (search_idx or 1) - 1
if index < 1 then
index = occurrences_len
end
else
index = (search_idx or 1) + 1
if index > occurrences_len then
index = 1
end
end
return index
end
function M:peek(job)
local subject_len, search_occurrences = get_search_occurrences(job.file.url)
local search_idx = job.search_idx
if search_idx == nil and search_occurrences then
local occurrence = search_occurrences[1]
local line = occurrence[1]
local skip = math.max(0, line - 1)
ya.emit("peek", {
skip,
only_if = job.file.url,
search_idx = 1,
})
return
end
local occurrence
if search_occurrences and search_idx then
occurrence = search_occurrences[search_idx]
job.position = {
line = occurrence[1],
col = occurrence[2],
length = subject_len,
}
end
local err, bound = ya.preview_code(job)
if bound then
ya.emit("peek", { bound, only_if = job.file.url, upper_bound = true })
ya.emit("peek", { bound, only_if = job.file.url, upper_bound = true, search_idx = search_idx })
elseif err and not err:find("cancelled", 1, true) then
require("empty").msg(job, err)
end
end
function M:seek(job)
local direction = job.units > 0 and "down" or "up"
local search_idx = cx.active.preview.search_idx
local h = cx.active.current.hovered
if not h or h.url ~= job.file.url then
return
end
local _, search_occurrences = get_search_occurrences(job.file.url)
if search_occurrences then
local next_occurrence_idx = get_next_occurrence_idx(search_idx, direction, #search_occurrences)
local occurrence = search_occurrences[next_occurrence_idx]
local line = occurrence[1]
local skip = math.max(0, line - 1)
ya.emit("peek", { skip, only_if = job.file.url, search_idx = next_occurrence_idx })
return
end
local step = math.floor(job.units * job.area.h / 10)
step = step == 0 and ya.clamp(-1, job.units, 1) or step
local skip = math.max(0, cx.active.preview.skip + step)
ya.emit("peek", {
math.max(0, cx.active.preview.skip + step),
skip,
only_if = job.file.url,
search_idx = search_idx,
})
end

View file

@ -1,4 +1,4 @@
use std::{path::Path, process::Stdio};
use std::process::Stdio;
use anyhow::Result;
use tokio::{io::{AsyncBufReadExt, BufReader}, process::Command, sync::mpsc::{self, UnboundedReceiver}};
@ -15,10 +15,10 @@ pub struct RgOpt {
pub fn rg(opt: RgOpt) -> Result<UnboundedReceiver<File>> {
let mut child = Command::new("rg")
.args(["--color=never", "--files-with-matches", "--smart-case"])
.args(["--color=never", "--no-heading", "--column", "--smart-case"])
.arg(if opt.hidden { "--hidden" } else { "--no-hidden" })
.args(opt.args)
.arg(opt.subject)
.arg(opt.subject.clone())
.current_dir(&*opt.cwd.as_url().unified_path())
.kill_on_drop(true)
.stdout(Stdio::piped())
@ -29,18 +29,67 @@ pub fn rg(opt: RgOpt) -> Result<UnboundedReceiver<File>> {
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
while let Ok(Some(line)) = it.next_line().await {
if Path::new(&line).is_absolute() {
continue;
let mut current_file: String = String::new();
let mut current_occurrences: Vec<(u32, u32)> = vec![];
while let Ok(Some(search_line)) = it.next_line().await {
let Some((file_path, line, col)) = parse_rg_line(&search_line) else { continue };
if current_file != file_path {
let Some(url) =
build_file_url(opt.cwd.clone(), opt.subject.clone(), &current_file, &current_occurrences)
else {
continue;
};
if let Ok(file) = File::new(url).await {
tx.send(file).ok();
}
current_file = file_path.clone();
current_occurrences = vec![];
}
let Ok(url) = opt.cwd.try_join(line) else {
continue;
};
if let Ok(file) = File::new(url).await {
tx.send(file).ok();
current_occurrences.push((line as u32, col as u32));
}
if current_occurrences.len() > 0 {
match build_file_url(opt.cwd, opt.subject, &current_file, &current_occurrences) {
Some(url) => {
if let Ok(file) = File::new(url).await {
tx.send(file).ok();
}
}
None => {}
}
}
child.wait().await.ok();
});
Ok(rx)
}
fn build_file_url(
cwd: UrlBuf,
subject: String,
file_path: &str,
occurences: &Vec<(u32, u32)>,
) -> Option<UrlBuf> {
let occurrences_str =
occurences.iter().map(|(line, col)| format!("{line}-{col}")).collect::<Vec<_>>().join(",");
let search_str = format!("{}~{}", subject.len(), occurrences_str);
let url = cwd.try_join(file_path).ok().and_then(|u| u.into_search(search_str).ok());
url
}
fn parse_rg_line(line: &str) -> Option<(String, usize, usize)> {
let mut parts = line.split(':');
let (file, line, col) = (parts.next()?, parts.next()?, parts.next()?);
if file.is_empty() {
return None;
}
Some((file.to_owned(), line.parse().ok()?, col.parse().ok()?))
}

View file

@ -1,5 +1,5 @@
use mlua::{ExternalError, Function, IntoLuaMulti, Lua, Table, Value};
use yazi_binding::{Error, elements::Area};
use yazi_binding::{Error, elements::{Area, HighlightPosition}};
use yazi_core::{Highlighter, MgrProxy, tab::PreviewLock};
use yazi_fs::FsUrl;
use yazi_runner::previewer::PeekError;
@ -15,10 +15,12 @@ impl Utils {
pub(super) fn preview_code(lua: &Lua) -> mlua::Result<Function> {
lua.create_async_function(|lua, t: Table| async move {
let area: Area = t.raw_get("area")?;
let mut lock = PreviewLock::try_from(t)?;
let position: Option<HighlightPosition> = t.raw_get("position").ok();
let mut lock = PreviewLock::try_from(t)?;
let path = lock.url.as_url().unified_path();
let inner = match Highlighter::oneshot(path, lock.skip, area.size()).await {
let inner = match Highlighter::oneshot(path, lock.skip, area.size(), position).await {
Ok(text) => text,
Err(e @ PeekError::Exceeded(max)) => return (e, max).into_lua_multi(&lua),
Err(e) => {
@ -33,6 +35,9 @@ impl Utils {
})
}
// Note: You need to implement or update Highlighter::oneshot_with_highlight to
// accept line/column and highlight accordingly.
pub(super) fn preview_widget(lua: &Lua) -> mlua::Result<Function> {
lua.create_async_function(|_, (t, value): (Table, Value)| async move {
let mut lock = PreviewLock::try_from(t)?;

View file

@ -6,10 +6,11 @@ use yazi_shared::{data::Sendable, pool::Symbol};
#[derive(Clone, Debug)]
pub struct PeekJob {
pub previewer: PreviewerArc,
pub file: File,
pub mime: Symbol<str>,
pub skip: usize,
pub previewer: PreviewerArc,
pub file: File,
pub mime: Symbol<str>,
pub skip: usize,
pub search_idx: Option<usize>,
}
impl IntoLua for PeekJob {
@ -21,6 +22,7 @@ impl IntoLua for PeekJob {
("file", self.file.into_lua(lua)?),
("mime", self.mime.into_lua(lua)?),
("skip", self.skip.into_lua(lua)?),
("search_idx", self.search_idx.into_lua(lua)?),
])?
.into_lua(lua)
}