quick_search/crates/quicksearch-gui/src/search_tab/snippet_render.rs
= 9760503a3f
Some checks failed
CI / linux (push) Failing after 5m46s
CI / windows-cross (push) Successful in 5m40s
CI / release (push) Has been skipped
Release v1.1: live results, duplicate verification, and a Settings tab.
Search results now track the disk as it changes (live.rs), duplicate
groups can be confirmed byte-for-byte before deletion (verify.rs), the
Options window becomes a Settings tab with configurable/sortable result
columns, and a first-start tutorial explains the basics.
2026-08-17 19:26:18 -04:00

347 lines
13 KiB
Rust

//! Rendering a [`Snippet`] into egui layout jobs: marked ranges,
//! middle-out elision around the first match, and row budgeting.
use super::*;
struct SnippetFormats {
normal: TextFormat,
highlight: TextFormat,
weak: TextFormat,
}
fn snippet_formats(ui: &egui::Ui) -> SnippetFormats {
let font_id = egui::TextStyle::Body.resolve(ui.style());
SnippetFormats {
normal: TextFormat {
font_id: font_id.clone(),
color: ui.visuals().text_color(),
..Default::default()
},
highlight: TextFormat {
font_id: font_id.clone(),
color: ui.visuals().strong_text_color(),
background: ui.visuals().selection.bg_fill.gamma_multiply(0.4),
..Default::default()
},
weak: TextFormat {
font_id,
color: ui.visuals().weak_text_color(),
..Default::default()
},
}
}
/// The mark on a snippet that starts partway into its window;
/// `first_visible_byte` pays for its width in advance.
const SNIPPET_LEAD: &str = "";
/// Append `window[range]` to `job`, highlighting whatever parts of `ranges`
/// (byte offsets into `window`) fall inside it.
///
/// Ranges are clipped to the slice, so a caller rendering a string in pieces
/// can hand each piece the *whole* set: a range inside this one survives, one
/// straddling an edge survives as the part that is here, and one wholly
/// outside disappears.
fn append_marked(
job: &mut LayoutJob,
fmt: &SnippetFormats,
window: &str,
ranges: &[(usize, usize)],
range: std::ops::Range<usize>,
) {
let mut cursor = range.start;
for &(a, b) in ranges {
let (a, b) = (a.max(range.start), b.min(range.end));
if a >= b {
continue; // wholly before or after the slice
}
if a > cursor {
job.append(&window[cursor..a], 0.0, fmt.normal.clone());
}
job.append(&window[a..b], 0.0, fmt.highlight.clone());
cursor = b;
}
if cursor < range.end {
job.append(&window[cursor..range.end], 0.0, fmt.normal.clone());
}
}
/// A whole field — a filename — with its matched spans marked.
///
/// Wrapping is left at the job's defaults on purpose: `egui::Label` overwrites
/// only `wrap.max_width`, so this is laid out exactly like the plain string it
/// replaces, and the cell keeps the height and clipping it had before.
pub(super) fn marked_field_job(ui: &egui::Ui, text: &str, ranges: &[(usize, usize)]) -> LayoutJob {
let fmt = snippet_formats(ui);
let mut job = LayoutJob::default();
append_marked(&mut job, &fmt, text, ranges, 0..text.len());
job
}
/// The byte offset in `snip.window` that rendering has to start at for the
/// first match to land on a row that survives `max_rows`; `0` when it
/// already does. epaint stops at `wrap.max_rows` and *every* `\n` costs a
/// row, blank line or not, so a ragged lead-in can spend the whole row
/// budget before layout reaches the match.
fn first_visible_byte(
ui: &egui::Ui,
snip: &Snippet,
fmt: &SnippetFormats,
max_rows: usize,
wrap_width: f32,
) -> usize {
let Some(&(match_start, _)) = snip.ranges.first() else {
return 0; // a head-of-file window, with nothing to keep on screen
};
// The rendered job pays for a leading mark this probe does not, so the
// probe wraps to a narrower width — a point narrower still, since epaint
// rounds `wrap.max_width` before laying out. Every rendered row then
// holds at least what the probe row starting at the same character held,
// so the match cannot drift *down* a row when the job is rebuilt.
let lead_width = ui.fonts(|f| {
SNIPPET_LEAD
.chars()
.map(|c| f.glyph_width(&fmt.normal.font_id, c))
.sum::<f32>()
});
let mut probe = LayoutJob::default();
probe.wrap.max_width = (wrap_width - lead_width - 1.0).max(1.0);
probe.append(&snip.window, 0.0, fmt.normal.clone());
let galley = ui.fonts(|f| f.layout_job(probe));
// Cursors index characters; snippet ranges are byte offsets. epaint
// counts the `\n` that ends a row, so the two spaces line up 1:1.
let cursor = egui::text::CCursor {
index: snip.window[..match_start].chars().count(),
// At a wrap, the character belongs to the row it is drawn on, not
// the one it was pushed off.
prefer_next_row: true,
};
let match_row = galley.layout_from_cursor(cursor).row;
// epaint trades a glyph or two off the end of the last visible row for
// its own overflow ellipsis, so a match sitting there only counts as
// visible when there was nothing below it to elide in the first place.
let visible_rows = if galley.rows.len() > max_rows {
max_rows.saturating_sub(1)
} else {
max_rows
};
if match_row < visible_rows {
return 0;
}
// Keep a third of the budget as lead-in so the hit is not pinned to the
// top edge.
let mut cursor = cursor;
for _ in 0..max_rows / 3 {
// `Some(0.0)` asks for the row above, not the character above.
cursor = galley.cursor_up_one_row(&cursor, Some(0.0)).0;
}
let start_char = galley.cursor_begin_of_row(&cursor).index;
snip.window
.char_indices()
.nth(start_char)
.map_or(snip.window.len(), |(i, _)| i)
}
/// Build a highlighted snippet LayoutJob from byte ranges, wrapped to at
/// most `max_rows` and started far enough into the window that the first
/// match survives the cap.
pub(super) fn snippet_job(ui: &egui::Ui, snip: &Snippet, max_rows: usize) -> LayoutJob {
let fmt = snippet_formats(ui);
// In a top-down `Ui`, `ui.label` overwrites `wrap.max_width` with exactly
// `ui.available_width()`; setting it here anyway lets `first_visible_byte`
// (and a test) lay out the rows the user will see.
let wrap_width = ui.available_width();
let start = first_visible_byte(ui, snip, &fmt, max_rows, wrap_width);
let mut job = LayoutJob::default();
job.wrap.max_width = wrap_width;
job.wrap.max_rows = max_rows;
if start > 0 || snip.truncated_start {
job.append(SNIPPET_LEAD, 0.0, fmt.weak.clone());
}
append_marked(
&mut job,
&fmt,
&snip.window,
&snip.ranges,
start..snip.window.len(),
);
if snip.truncated_end {
job.append("", 0.0, fmt.weak);
}
job
}
/// The Content Match column cell: one line with the (first) matched span
/// centered and an equal amount of context on both sides, trimmed to what fits
/// the column width.
///
/// Only ever called with a content snippet. Name and path matches are
/// highlighted in their own columns and leave a dash here, so the bracketed
/// `[whole field]` rendering this used to carry is gone.
pub(super) fn centered_match_job(ui: &egui::Ui, snip: &Snippet, width_px: f32) -> LayoutJob {
let fmt = snippet_formats(ui);
// Newlines force line breaks even in a one-row LayoutJob; flatten them
// to spaces — a byte-for-byte ASCII replacement, so the match ranges
// stay valid. The mouseover renders the original window untouched.
let flattened: Option<String> = snip
.window
.contains(['\n', '\r', '\t'])
.then(|| snip.window.replace(['\n', '\r', '\t'], " "));
let window = flattened.as_deref().unwrap_or(&snip.window);
// The budget is in pixels, summed from the font's own glyph advances:
// the centered-and-justified layout puts egui in Extend mode, which lays
// the job out at infinite width, and `Column::clip` then trims a
// *centered* overflow from both ends at once — silently, taking the
// highlighted match with it.
let (start, end, decorate) = ui.fonts(|f| {
let font_id = &fmt.normal.font_id;
let width_of = |c: char| f.glyph_width(font_id, c);
let ellipsis = width_of('…');
let mut marks = 0.0;
if snip.truncated_start {
marks += ellipsis;
}
if snip.truncated_end {
marks += ellipsis;
}
if fits_within(window, width_px - marks, width_of) {
return (0, window.len(), true);
}
// Something has to go, so either end may gain a mark; reserve for
// both so a cut never overflows the column.
let budget = width_px - 2.0 * ellipsis;
let Some(&(a, b)) = snip.ranges.first() else {
// No ranges (shouldn't happen for match cells) — head trim.
return (0, take_forward(window, 0, budget.max(0.0), width_of), true);
};
if budget <= 0.0 {
// A column narrower than its own punctuation: spend every point
// on the hit and drop the decoration.
return (a, take_forward(window, a, width_px, width_of), false);
}
if !fits_within(&window[a..b], budget, width_of) {
// A hit wider than the whole column — a greedy regex or wildcard
// match. Its beginning is the part that has to survive.
return (a, take_forward(window, a, budget, width_of), true);
}
let match_w: f32 = window[a..b].chars().map(width_of).sum();
// Equal context on both sides, grown outward one character at a
// time; whichever side is currently narrower is fed first, so the
// leftover from a short side flows to the other.
let (mut start, mut end) = (a, b);
let (mut before_w, mut after_w) = (0.0f32, 0.0f32);
loop {
let prev = window[..start].chars().next_back();
let next = window[end..].chars().next();
let used = before_w + match_w + after_w;
let prev_fits = prev.is_some_and(|c| used + width_of(c) <= budget);
let next_fits = next.is_some_and(|c| used + width_of(c) <= budget);
if !prev_fits && !next_fits {
break;
}
// The preferred side wins when it fits; otherwise the other one
// does, since at least one of them just did.
let take_prev = if before_w <= after_w {
prev_fits
} else {
!next_fits
};
if take_prev {
let c = prev.expect("prev_fits");
start -= c.len_utf8();
before_w += width_of(c);
} else {
let c = next.expect("next_fits");
end += c.len_utf8();
after_w += width_of(c);
}
}
(start, end, true)
});
let mut job = LayoutJob::default();
job.wrap.max_rows = 1;
job.wrap.break_anywhere = true;
if decorate && (start > 0 || snip.truncated_start) {
job.append("", 0.0, fmt.weak.clone());
}
append_marked(&mut job, &fmt, window, &snip.ranges, start..end);
if decorate && (end < window.len() || snip.truncated_end) {
job.append("", 0.0, fmt.weak);
}
job
}
/// The Path column cell: middle-elided to `width_px`, with whatever of a
/// path-tier match survives the cut highlighted.
///
/// The path reads at full strength — it is the one column that identifies a
/// result on its own. Only the elision mark is weak, since it is punctuation
/// this renderer added rather than anything the file is named.
///
/// Returns the job and whether anything was actually elided — the caller's
/// trigger for a full-path tooltip, since egui offers one only when *it* did
/// the eliding and it is handed an already-shortened string.
pub(super) fn path_cell_job(
ui: &egui::Ui,
path: &str,
ranges: &[(usize, usize)],
width_px: f32,
font_id: &egui::FontId,
) -> (LayoutJob, bool) {
let fmt = snippet_formats(ui);
let mut job = LayoutJob::default();
match crate::ui_util::middle_elide_cut(ui, path, width_px, font_id) {
// It fits: the whole path, marked — which is exactly
// [`marked_field_job`].
None => (marked_field_job(ui, path, ranges), false),
// The two surviving ends are appended straight from `path` at their
// original offsets: `append_marked` clips the ranges to each end, so a
// match that fell in the dropped middle drops with it rather than
// landing on whatever glyphs moved into those offsets.
Some((head, tail)) => {
append_marked(&mut job, &fmt, path, ranges, 0..head);
job.append("", 0.0, fmt.weak.clone());
append_marked(&mut job, &fmt, path, ranges, tail..path.len());
(job, true)
}
}
}
/// Whether the whole of `text` fits in `budget` pixels; stops at the first
/// character that does not.
fn fits_within(text: &str, budget: f32, width_of: impl Fn(char) -> f32) -> bool {
let mut used = 0.0;
for c in text.chars() {
used += width_of(c);
if used > budget {
return false;
}
}
true
}
/// The byte offset one past the last character of `text[from..]` that still
/// fits in `budget` pixels.
fn take_forward(text: &str, from: usize, budget: f32, width_of: impl Fn(char) -> f32) -> usize {
let mut end = from;
let mut used = 0.0;
for c in text[from..].chars() {
let w = width_of(c);
if used + w > budget {
break;
}
used += w;
end += c.len_utf8();
}
end
}