Files, spectra and warm-ups¶
Modules for terminal apps that show the outside world: a file manager's
icons, MIME types, times and owners, listings, openers, file operations,
trash, git status and archives; the spectrum of a sampled signal; and
syntax highlighting warmed up before the first frame needs it.
intuiTUIve's Yazi and scope-tui rebuilds (and its files components) use
them, but none needs intuiTUIve.
File-type icons and MIME types¶
rich_ext::filetype (no feature, no dependencies) gives the glyph and
colour Yazi shows by default for a file:
its theme-dark.toml and theme-light.toml presets, whose glyphs and
colours come from nvim-web-devicons.
use std::path::Path;
use rich_ext::filetype::{icon_for, icon_with, Appearance, FileFlags};
let (glyph, colour) = icon_for(Path::new("main.rs"), false);
let line = format!("[{colour}]{glyph}[/] main.rs"); // console markup
// Links, devices, executables, the hovered folder, the light preset:
let flags = FileFlags::of(path)?;
let icon = icon_with(path, flags, Appearance::Light);
The lookup is Yazi's: a directory by its name; a file by its name, then its
last extension (gz for a.tar.gz), each exactly and then in lower case;
then the conditions (an orphan link, a link, a device, a directory, an
executable, any file).
- Type colours:
style_for(mime, flags)is Yazi's[filetype]rules as a rich style: imagesyellow, audio and videomagenta, archivesred, documentscyan, an orphan linkon red, an executablegreen, a directoryblue. - MIME types:
mime_for_path(path)goes by the extension (about a hundred common ones),mime_from_bytes(head)by magic numbers (images, audio and video containers, archives, PDF, fonts, SQLite, WebAssembly, ELF, Mach-O and PE), andmime_for(path)reads at most 512 bytes and tries both, answeringinode/directory,inode/x-empty,text/plainorapplication/octet-streamwhen nothing else fits. - In the background:
mime_batch(&paths)sniffs many files at once (at most 512 bytes each), for a listing that colours its rows by type once a worker thread has read them, as Yazi's fetchers do. - Nerd Fonts: the glyphs need one. No terminal reports its font, and the
glyphs are in the Private Use Area, so a cursor-position probe measures
them as one cell either way: nothing can detect the font.
nerd_font()says what can be known:RICH_NERD_FONT(orNERD_FONT) set to1or0, else whether a Nerd Font file is installed in the usual font directories (which the terminal may not use, and which a session over SSH cannot see).nerd_font().likely()is the yes or no.
Local times and owners¶
rich_ext::fs fills a listing's other columns.
use rich_ext::fs::{group_name, short_local_time, user_name};
let meta = std::fs::metadata(path)?;
let when = short_local_time(meta.modified()?); // `10/18 14:05`, or `10/18 2024`
#[cfg(unix)]
let owner = {
use std::os::unix::fs::MetadataExt;
format!("{}:{}", user_name(meta.uid()).unwrap_or_default(), group_name(meta.gid()).unwrap_or_default())
};
local_time(time, "%Y-%m-%d %H:%M")formats withstrftimecodes in the local time zone;short_local_timeisls -l's and Yazi's short form. Both need the off-by-defaultlocal-timefeature (chrono with itsclock, reading the zone through iana-time-zone).user_name(uid)andgroup_name(gid)look the system's user database up (getpwuid_r,getgrgid_r; no extra dependency) and remember each answer. Off Unix they returnNone.
Directory listings and walks¶
rich_ext::listing (no feature, no dependencies) reads a directory as a
file manager shows it:
use rich_ext::listing::{read_dir, readable_size, Sort, SortBy};
for entry in read_dir(dir, false, Sort::by(SortBy::Natural))? {
println!("{:>6} {}", readable_size(entry.size), entry.name);
}
Entryhas the name, whether it is a directory (a link to one counts), its size, mode, owner, times, and where a link points (and whether its target is missing).Entry::read(dir, name)reads one.Sortis Yazi'ssort_by: alphabetical (the default), natural (a2beforea10), modified, created, extension, size or random, reversed or not, directories first unlessdirs_firstis off. Hidden files (a leading dot) are left out unless asked for.- Line-mode cells:
readable_size(1.4K),permissions(drwxr-xr-x),owner(user:group);find_ranges(name, query)is where a smart-case search matches a name. walk(root)goes through a tree depth first as an iterator, reading each directory only when it gets there, so a finder shows its first results at once:.hidden(true),.max_depth(n),.skip_dir(".git"). Links are listed, never followed.
Opening files¶
rich_ext::open is Yazi's [opener] and [open] tables as data. An
Opener says how (Launch::Editor, System, Reveal, Shell(run),
Extract) and whether it takes the terminal (block); a Rule picks
openers by MIME type or name glob (text/*, {audio,video}/*, */ for a
directory); the first rule that matches decides.
use rich_ext::open::{Launch, Opener, Rule, Rules};
let rules = Rules::yazi()
.opener(Opener::new("view", "View", Launch::Shell("less \"$@\"".into())).block(true))
.prepend(Rule::name("*.log", &["view", "edit"]));
let openers = rules.matching(path, mime); // the first is what Enter uses
let commands = openers[0].commands(&[path.to_path_buf()]);
It builds Commands and runs nothing: editor() is $EDITOR (else
vi), system_opener() is $OPENER (else xdg-open, open, or cmd /C
start), and a shell command runs in sh -c with the files as "$@".
rules.shared(&files) is what every file of a selection can be opened
with. intuiTUIve's files::open_with runs one from an app.
File operations¶
rich_ext::file_ops::run(&op, &cancel, &mut progress) copies, moves,
deletes or trashes, calling progress with bytes and items done (after
every 256 KiB) and stopping at the next of those once the
CancelToken is cancelled. A file half copied
is removed; what was done before stays.
use rich_ext::cancel::CancelToken;
use rich_ext::file_ops::{run, Conflict, Op};
let op = Op::Copy { from: paths, into: dir, conflict: Conflict::Rename };
let report = run(&op, &CancelToken::new(), &mut |p| println!("{:.0}%", p.fraction() * 100.0));
- A name that is taken gets
_1,_2… before its extension (Conflict::Rename, Yazi's default), or is replaced (Overwrite) or left (Skip);unique(path)is the rule on its own. - A directory is never pasted into itself; a cut pasted where it is stays.
- A move is a rename where it can be, else a copy and a delete.
Op::title()names it for a task list (Copy 3 items to docs);under(dir, typed)turns a typed name into a path that cannot climb out ofdir.
The trash¶
rich_ext::trash keeps the
FreeDesktop.org trash,
the one GNOME Files, Dolphin, Thunar and trash-cli share:
let item = rich_ext::trash::put(path)?; // moved, with where and when
for item in rich_ext::trash::list()? { println!("{}", item.original.display()); }
item.restore()?; // back where it was
A file on the home directory's file system goes to ~/.local/share/Trash;
one on another mount to that mount's .Trash/$uid or .Trash-$uid, so
nothing is copied across devices. On macOS and Windows, whose bins the
Finder and the shell keep, put and list return an Unsupported error
that says so. Trash::at(dir) is a trash directory you name, anywhere
(the tests use one).
Git status¶
rich_ext::git::status(dir) runs git status --porcelain=v1 -z
--ignored=matching (no git library) and returns a GitStatus, or None
outside a work tree. status.of(path) says what happened to a path:
Change::Modified, Added, Deleted, Untracked, Ignored or
Conflicted; a directory takes the greatest change inside it, and a file
in an untracked or ignored directory takes the directory's. letter(),
glyph() and style() are the sign and colour Yazi's git plugin shows.
parse_porcelain(root, bytes) reads output taken some other way.
Archives¶
rich_ext::archive (the archive feature: zip with deflate only, and
flate2's pure-Rust backend) reads tar, tar.gz and zip files:
use rich_ext::archive::{default_dest, entries, extract};
for entry in entries(path)? { println!("{} {}", entry.size, entry.path); }
let done = extract(path, &default_dest(path))?; // `docs.tar.gz` → `docs/`
Extraction is careful with what an archive says: an entry that is absolute
or climbs out with .. is refused, links are not made, nothing is
overwritten and set-id bits are dropped; done.skipped lists what was
left out and why. extract_with reports progress and can be cancelled,
as file_ops::run does. Tar is read by the module itself (ustar, GNU long
names, pax paths).
FFT and spectrum¶
rich_ext::dsp (no feature, no dependencies) has a fast Fourier transform
of any length (radix 2 for a power of two, Bluestein's chirp transform
otherwise), the Hann window, and a magnitude spectrum:
use rich_ext::dsp::{hann, spectrum};
// (Hz, magnitude) from 0 Hz to the Nyquist frequency, rate / n apart.
let bins = spectrum(&hann(&samples), 48_000.0);
fft(&samples) returns the complex bins as (re, im) pairs, and
magnitude(bin) their size. spectrum_full(samples, rate) gives every
bin, the mirrored half above the Nyquist frequency too, as scope-tui plots
them. intuiTUIve's audio::input (its audio feature) gives the samples
to feed it, a buffer at a time.
Test signals, triggers and notes¶
The same module makes signals to test a scope with, and finds what one shows:
use rich_ext::dsp::{mix, noise, note_hz, partials, tone, trigger, Edge};
let rate = 48_000.0;
let a4 = note_hz("A4").unwrap(); // 440.0
let mut signal = mix()
.add(0.6, tone(a4, rate))
.add(0.3, partials(a4 * 1.5, rate, &[1.0, 0.5, 0.25]))
.add(0.05, noise(7).low_pass(300.0, rate));
let buffer: Vec<f64> = signal.by_ref().take(2048).collect();
// Where to start drawing, so the wave stands still.
let start = trigger(&buffer, 0.0, 4, Edge::Rising).unwrap_or(0);
tone(hz, rate)is an endless sine (.amplitude(a),.phase(rad)) whose phase carries on from buffer to buffer;partials(hz, rate, &litudes)a note with its overtones, those at or past the Nyquist frequency left out;noise(seed)white noise, the same for the same seed, and.low_pass(hz, rate)smooths it into a wander.mix()adds sources, each with a gain; all are iterators of samples.trigger(samples, level, depth, edge)finds where the samples crosslevel(Edge::RisingorFalling) and stay past it fordepthsamples: an oscilloscope's trigger.note_hz("C#3")is a note's frequency in equal temperament, A4 at 440 Hz (sharps#, flatsb, octaves -1 to 9, 4 when left out).NoteTable::ScopeTui.hz("E2")reads it as scope-tui's table does: octave 0's pitches rounded to the hundredth and doubled, octave 0 when left out, so a clone's--tunebuffer sizes match scope-tui's sample for sample (E2 at 48 kHz is 583 samples there, 582 in equal temperament).peak_hz(spectrum)is the loudest bin's frequency above 0 Hz, refined between bins, andnearest_note(hz)the note nearest a frequency, as("A", 4, cents): a tuner's reading.bands(spectrum, n, lo_hz, hi_hz)gathers a spectrum intonlog-spaced bands, as cava's bars, andFalloff::gravity(g)keeps levels that rise at once and fall faster and faster (update(&levels)once a frame);chart::BarSpectrumdraws them.
Warming up syntax highlighting¶
Core's default highlighter loads syntect's grammars and themes the first
time anything is highlighted, and compiles a grammar's patterns the first
time they are tried. In a debug build that first highlight takes seconds,
so an interactive app's first Markdown preview stalls. rich_ext::syntax
(with the default syntax feature) does that work on a worker thread:
let warming = rich_ext::syntax::preload(&["rust", "markdown", "toml"]);
// … build the app; the first preview finds the grammars loaded.
preload returns at once with a Preload (is_finished(), wait());
preload_with(highlighter, languages, themes) warms another
highlighter and more themes. It works through the
highlighter's public API, so core is unchanged: the state it warms is the
highlighter's own, shared by every Syntax and Markdown in the process.