Add full-text search functionality with a dedicated search view

- Introduced `search_package.rs` example for searching package contents with timing reports.
- Implemented `search.rs` for full-text search capabilities across various file types, including regex support and match previews.
- Created `icons.rs` for custom vector icons resembling VS Code's codicons.
- Developed `search_view.rs` for a sidebar search interface, featuring a query box, option toggles, and results display grouped by file.
- Added progress indicators and status messages to enhance user experience during searches.
This commit is contained in:
Jan Kammerath committed 2026-10-28 20:55:52 +01:00
1 parent 3bd28aefd1
commit 24af8645c5
14 files changed
+2556 -459

No files matched your search

Generated
+39
View File
@@ -127,6 +127,15 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "android-activity"
version = "0.6.1"
@@ -486,6 +495,7 @@ dependencies = [
"flate2",
"image",
"raw-window-handle",
"regex",
"rfd",
"sha2",
"windows-sys 0.59.0",
@@ -2607,6 +2617,35 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "renderdoc-sys"
version = "1.1.0"
+1
View File
@@ -12,6 +12,7 @@ egui_extras = { version = "0.32", features = ["image"] }
flate2 = "1.1.10"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
raw-window-handle = "0.6"
regex = "1.13.1"
rfd = "0.17.2"
sha2 = "0.11.0"
+8 -1
View File
@@ -4,7 +4,8 @@ BogusMips is written in Rust and allows performing analyses on how far your Andr
## Features
- Open an `*.apk` or `*.xapk` with **File → Open…** (⌘O / Ctrl+O), by dropping it onto the window, or as a command-line argument.
- VS Code style interface (Dark+ colors): activity bar with the application menu (☰), **Explorer** and **Search** views, an editor with tab and breadcrumbs, a file information panel and a status bar.
- Open an `*.apk` or `*.xapk` with **☰ → File → Open…** (⌘O / Ctrl+O), by dropping it onto the window, or as a command-line argument.
- **Left: package tree**
- **Source code**: every class from all `classes*.dex` files, grouped by Java package and disassembled to smali.
- **Resources**: `resources.arsc` decoded into the `res/values*/*.xml` files it was compiled from (strings, colors, dimens, styles, attrs, arrays, plurals, …) plus `public.xml` with every resource ID.
@@ -12,6 +13,7 @@ BogusMips is written in Rust and allows performing analyses on how far your Andr
- XAPK bundles show each contained APK (base and split APKs) separately.
- Filter box (⌘F) and keyboard navigation with the arrow keys.
- Right-click an item (or **File → Export…**, ⌘E) to export it: files as stored in the package, or **as shown** (decoded binary XML, beautified text); classes as `.smali`; generated resource files as XML; APKs contained in an XAPK as `.apk`.
- **Search** (⇧⌘F): searches the contents of all classes (smali), text files, decoded XML and generated resource files, with match case / whole word / regular expression options. Clicking a result opens it in the editor at the highlighted match. The search index is built in the background on the first search.
- **Center: content of the selected item**
- Classes are shown as smali (baksmali-compatible output).
- Binary XML (`AndroidManifest.xml`, `res/**/*.xml`) is decoded. Resource references are resolved to names (`@string/app_name`, `@android:style/Theme`), and attribute values to their symbolic names (`protectionLevel="signature"`).
@@ -51,6 +53,8 @@ cargo run --release --example dump_smali -- app.apk out-dir
cargo run --release --example dump_resources -- app.apk out-dir
# Beautify a file as the content view does (format detected from the name, or given)
cargo run --release --example beautify_file -- in.min.js out.js [json|xml|html|css|js]
# Search a package's contents like the Search view, with timings
cargo run --release --example search_package -- app.apk "getString" [--regex] [--case] [--word]
# Open packages and inspect every node, reporting timings and decode errors
cargo run --release --example inspect_all -- app.apk other.xapk
```
@@ -63,6 +67,7 @@ src/
├── lib.rs
├── app.rs # application state, menus, panels, background loading
├── inspect.rs # turns a tree node into content + file information
├── search.rs # content search: background index, regex search
├── beautify/ # whitespace-only pretty-printers: JSON, XML/HTML, CSS, JavaScript
├── package/
│ ├── mod.rs # APK/XAPK loading, entry extraction, manifest summary
@@ -87,5 +92,7 @@ src/
├── tree_view.rs # left panel
├── content_view.rs # center panel (text, hex, image)
├── info_panel.rs # right panel
├── search_view.rs # sidebar search view
├── icons.rs # vector icons (codicon style)
└── highlight.rs # smali / XML syntax highlighting
```
+64
View File
@@ -0,0 +1,64 @@
//! Developer tool: searches the contents of a package like the Search view,
//! reporting index and search timings.
//!
//! Usage: cargo run --release --example search_package -- <app.apk> <query> [--regex] [--case] [--word]
use std::sync::atomic::AtomicBool;
use std::time::Instant;
use bogusmips::package::Package;
use bogusmips::search::{Progress, Query, build_corpus, search};
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let (Some(path), Some(text)) = (args.first(), args.get(1)) else {
eprintln!("usage: search_package <app.apk> <query> [--regex] [--case] [--word]");
std::process::exit(2);
};
let flag = |f: &str| args.iter().any(|a| a == f);
let query = Query {
text: text.clone(),
regex: flag("--regex"),
match_case: flag("--case"),
whole_word: flag("--word"),
};
let regex = query.compile().unwrap_or_else(|e| {
eprintln!("invalid query: {e}");
std::process::exit(2);
});
let pkg = Package::open(std::path::Path::new(path)).expect("readable package");
let start = Instant::now();
let corpus =
build_corpus(&pkg, &AtomicBool::new(false), &Progress::default()).expect("not cancelled");
let bytes: usize = corpus.docs.iter().map(|d| d.text_len()).sum();
println!(
"indexed {} documents ({} MiB) in {:?}",
corpus.docs.len(),
bytes >> 20,
start.elapsed()
);
let start = Instant::now();
let results = search(&corpus, &regex, &AtomicBool::new(false)).expect("not cancelled");
println!(
"{} results in {} files{} in {:?}",
results.total_matches,
results.files.len(),
if results.truncated {
" (truncated)"
} else {
""
},
start.elapsed()
);
for file in results.files.iter().take(5) {
for m in file.matches.iter().take(2) {
println!(
" {}:{} {}",
pkg.node_path(file.node),
m.line + 1,
m.preview
);
}
}
}
+637 -292
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -6,4 +6,5 @@ pub mod export;
pub mod formats;
pub mod inspect;
pub mod package;
pub mod search;
pub mod ui;
+658
View File
@@ -0,0 +1,658 @@
//! Full-text search over everything the content view can show as text:
//! classes (smali), text files, decoded binary XML and the generated resource
//! files.
//!
//! The searchable text is built once per package (in parallel, in the
//! background) from exactly the lines the content view displays, so match
//! line numbers can be used to jump to the match.
use std::ops::Range;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
use regex::{Regex, RegexBuilder};
use crate::formats::detect::{FileKind, detect};
use crate::inspect::{Content, inspect};
use crate::package::{NodeId, NodeKind, Package};
/// Results beyond this are not collected (the count says so).
pub const MAX_MATCHES: usize = 20_000;
const PREVIEW_BEFORE: usize = 40;
const PREVIEW_AFTER: usize = 160;
/// Searching starts this long after the last edit of the query.
const DEBOUNCE: Duration = Duration::from_millis(250);
pub struct Doc {
pub node: NodeId,
text: String,
line_starts: Vec<usize>,
}
impl Doc {
fn new(node: NodeId, lines: &[String]) -> Self {
let mut text = String::with_capacity(lines.iter().map(|l| l.len() + 1).sum());
let mut line_starts = Vec::with_capacity(lines.len());
for line in lines {
line_starts.push(text.len());
text.push_str(line);
text.push('\n');
}
Self {
node,
text,
line_starts,
}
}
pub fn text_len(&self) -> usize {
self.text.len()
}
fn line_of(&self, offset: usize) -> usize {
self.line_starts
.partition_point(|&start| start <= offset)
.saturating_sub(1)
}
fn line_range(&self, line: usize) -> Range<usize> {
let start = self.line_starts[line];
// Every line, including the last, is followed by a '\n'.
let end = self
.line_starts
.get(line + 1)
.map_or(self.text.len() - 1, |next| next - 1);
start..end
}
}
pub struct Corpus {
pub docs: Vec<Doc>,
}
/// The text the content view shows for `node`, if it is searchable.
pub fn searchable_lines(pkg: &Package, node: NodeId) -> Option<Vec<String>> {
match &pkg.nodes[node].kind {
NodeKind::File(f) => {
let data = pkg.read_file(*f).ok()?;
if !matches!(
detect(pkg.files[*f].name(), &data.bytes),
FileKind::Text | FileKind::BinaryXml
) {
return None;
}
}
NodeKind::Class(_) | NodeKind::ResourceFile(..) => {}
_ => return None,
}
match inspect(pkg, node).content {
Content::Text { lines, .. } => Some(lines),
_ => None,
}
}
/// Builds the corpus using all CPU cores. Returns `None` when cancelled.
pub fn build_corpus(pkg: &Package, cancel: &AtomicBool, progress: &Progress) -> Option<Corpus> {
let candidates: Vec<NodeId> = (0..pkg.nodes.len())
.filter(|&n| {
matches!(
pkg.nodes[n].kind,
NodeKind::File(_) | NodeKind::Class(_) | NodeKind::ResourceFile(..)
)
})
.collect();
progress.total.store(candidates.len(), Ordering::Relaxed);
progress.done.store(0, Ordering::Relaxed);
let threads = std::thread::available_parallelism()
.map_or(4, |n| n.get())
.clamp(1, 16);
let chunk = candidates.len().div_ceil(threads).max(1);
let parts: Vec<Option<Vec<Doc>>> = std::thread::scope(|scope| {
let handles: Vec<_> = candidates
.chunks(chunk)
.map(|nodes| {
scope.spawn(move || {
let mut docs = Vec::new();
for &node in nodes {
if cancel.load(Ordering::Relaxed) {
return None;
}
let lines = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
searchable_lines(pkg, node)
}))
.ok()
.flatten();
if let Some(lines) = lines {
docs.push(Doc::new(node, &lines));
}
progress.done.fetch_add(1, Ordering::Relaxed);
}
Some(docs)
})
})
.collect();
handles
.into_iter()
.map(|h| h.join().ok().flatten())
.collect()
});
let mut docs = Vec::new();
for part in parts {
docs.extend(part?);
}
Some(Corpus { docs })
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Query {
pub text: String,
pub match_case: bool,
pub whole_word: bool,
pub regex: bool,
}
impl Query {
pub fn compile(&self) -> Result<Regex, String> {
let mut pattern = if self.regex {
self.text.clone()
} else {
regex::escape(&self.text)
};
if self.whole_word {
pattern = format!(r"\b(?:{pattern})\b");
}
RegexBuilder::new(&pattern)
.case_insensitive(!self.match_case)
.multi_line(true)
.size_limit(16 << 20)
.build()
.map_err(|e| match e {
regex::Error::Syntax(s) => s.lines().last().unwrap_or(&s).trim().to_owned(),
other => other.to_string(),
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LineMatch {
/// 0-based line in the document.
pub line: usize,
/// Byte range of the match within the line.
pub range: Range<usize>,
pub preview: String,
/// Byte range of the match within `preview`.
pub preview_range: Range<usize>,
}
#[derive(Clone, Debug)]
pub struct FileResult {
pub node: NodeId,
pub matches: Vec<LineMatch>,
}
#[derive(Clone, Debug, Default)]
pub struct Results {
pub files: Vec<FileResult>,
pub total_matches: usize,
pub truncated: bool,
}
fn floor_boundary(s: &str, mut i: usize) -> usize {
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
fn ceil_boundary(s: &str, mut i: usize) -> usize {
while i < s.len() && !s.is_char_boundary(i) {
i += 1;
}
i
}
/// A short excerpt of `line` around `range`, without leading indentation.
fn preview(line: &str, range: &Range<usize>) -> (String, Range<usize>) {
let indent = line.len() - line.trim_start().len();
let start = floor_boundary(
line,
range
.start
.saturating_sub(PREVIEW_BEFORE)
.max(indent.min(range.start)),
);
let end = ceil_boundary(line, (range.end + PREVIEW_AFTER).min(line.len()));
let mut text = String::new();
if start > indent {
text.push('…');
}
let offset = text.len();
text.push_str(&line[start..end]);
if end < line.len() {
text.push('…');
}
let match_start = offset + range.start - start;
let match_end = offset + range.end.min(end) - start;
(text, match_start..match_end)
}
pub fn search(corpus: &Corpus, regex: &Regex, cancel: &AtomicBool) -> Option<Results> {
let mut results = Results::default();
for doc in &corpus.docs {
if cancel.load(Ordering::Relaxed) {
return None;
}
let mut matches = Vec::new();
for m in regex.find_iter(&doc.text) {
if m.start() == m.end() {
continue;
}
results.total_matches += 1;
if results.total_matches > MAX_MATCHES {
results.truncated = true;
continue;
}
let line = doc.line_of(m.start());
let line_range = doc.line_range(line);
let line_text = &doc.text[line_range.clone()];
let range =
(m.start() - line_range.start)..(m.end().min(line_range.end) - line_range.start);
let (preview, preview_range) = preview(line_text, &range);
matches.push(LineMatch {
line,
range,
preview,
preview_range,
});
}
if !matches.is_empty() {
results.files.push(FileResult {
node: doc.node,
matches,
});
}
}
Some(results)
}
#[derive(Default)]
pub struct Progress {
pub indexing: AtomicBool,
pub done: AtomicUsize,
pub total: AtomicUsize,
}
struct Job {
query: Query,
rx: Receiver<Option<Results>>,
cancel: Arc<AtomicBool>,
}
#[derive(Default)]
struct IndexState {
corpus: Option<Arc<Corpus>>,
/// Indexing has ended (successfully or not).
finished: bool,
}
/// The package's search index, built once in the background and shared by
/// all searches. Changing the query never restarts it.
struct Index {
state: Arc<(Mutex<IndexState>, Condvar)>,
cancel: Arc<AtomicBool>,
progress: Arc<Progress>,
}
impl Index {
fn start(pkg: Arc<Package>) -> Self {
let state: Arc<(Mutex<IndexState>, Condvar)> = Arc::default();
let cancel = Arc::new(AtomicBool::new(false));
let progress = Arc::new(Progress::default());
progress.indexing.store(true, Ordering::Relaxed);
let (s, c, p) = (state.clone(), cancel.clone(), progress.clone());
std::thread::spawn(move || {
let corpus = build_corpus(&pkg, &c, &p).map(Arc::new);
p.indexing.store(false, Ordering::Relaxed);
let (lock, ready) = &*s;
if let Ok(mut st) = lock.lock() {
st.corpus = corpus;
st.finished = true;
}
ready.notify_all();
});
Self {
state,
cancel,
progress,
}
}
/// Waits until the corpus is ready; `None` if indexing failed or the
/// search was cancelled meanwhile.
fn wait(state: &(Mutex<IndexState>, Condvar), cancel: &AtomicBool) -> Option<Arc<Corpus>> {
let (lock, ready) = state;
let mut st = lock.lock().ok()?;
loop {
if cancel.load(Ordering::Relaxed) {
return None;
}
if st.finished {
return st.corpus.clone();
}
st = ready.wait_timeout(st, Duration::from_millis(50)).ok()?.0;
}
}
}
impl Drop for Index {
fn drop(&mut self) {
self.cancel.store(true, Ordering::Relaxed);
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Status {
Idle,
Indexing { done: usize, total: usize },
Searching,
}
/// Runs searches in the background as the query changes.
pub struct SearchController {
pub query: Query,
pub results: Option<Arc<Results>>,
/// The query `results` belong to.
pub results_query: Option<Query>,
pub error: Option<String>,
index: Option<Index>,
job: Option<Job>,
edited: Option<Instant>,
}
impl Default for SearchController {
fn default() -> Self {
Self::new()
}
}
impl SearchController {
pub fn new() -> Self {
Self {
query: Query::default(),
results: None,
results_query: None,
error: None,
index: None,
job: None,
edited: None,
}
}
/// Call after the query changed; the search starts after a short delay.
pub fn query_changed(&mut self) {
self.edited = Some(Instant::now());
}
/// Starts searching immediately (e.g. on Enter).
pub fn search_now(&mut self) {
self.edited = Some(Instant::now() - DEBOUNCE);
}
pub fn status(&self) -> Status {
match (&self.job, &self.index) {
(None, _) => Status::Idle,
(Some(_), Some(index)) if index.progress.indexing.load(Ordering::Relaxed) => {
Status::Indexing {
done: index.progress.done.load(Ordering::Relaxed),
total: index.progress.total.load(Ordering::Relaxed),
}
}
(Some(_), _) => Status::Searching,
}
}
pub fn is_busy(&self) -> bool {
self.job.is_some() || self.edited.is_some()
}
fn cancel(&mut self) {
if let Some(job) = self.job.take() {
job.cancel.store(true, Ordering::Relaxed);
}
}
/// Advances the state machine; call once per frame.
pub fn poll(&mut self, pkg: &Arc<Package>, ctx: &eframe::egui::Context) {
if let Some(job) = &self.job {
match job.rx.try_recv() {
Ok(Some(results)) => {
self.results = Some(Arc::new(results));
self.results_query = Some(job.query.clone());
self.job = None;
}
Ok(None) | Err(TryRecvError::Disconnected) => self.job = None,
Err(TryRecvError::Empty) => {}
}
}
if let Some(edited) = self.edited {
let wait = DEBOUNCE.saturating_sub(edited.elapsed());
if wait.is_zero() {
self.edited = None;
self.start(pkg, ctx);
} else {
ctx.request_repaint_after(wait);
}
}
if self.job.is_some() {
ctx.request_repaint_after(Duration::from_millis(100));
}
}
fn start(&mut self, pkg: &Arc<Package>, ctx: &eframe::egui::Context) {
self.cancel();
self.error = None;
if self.query.text.is_empty() {
self.results = None;
self.results_query = None;
return;
}
let regex = match self.query.compile() {
Ok(r) => r,
Err(e) => {
self.error = Some(e);
self.results = None;
self.results_query = None;
return;
}
};
let index = self.index.get_or_insert_with(|| Index::start(pkg.clone()));
let (tx, rx) = channel();
let cancel = Arc::new(AtomicBool::new(false));
let (state, job_cancel, ctx) = (index.state.clone(), cancel.clone(), ctx.clone());
std::thread::spawn(move || {
let results = Index::wait(&state, &job_cancel)
.and_then(|corpus| search(&corpus, &regex, &job_cancel));
let _ = tx.send(results);
ctx.request_repaint();
});
self.job = Some(Job {
query: self.query.clone(),
rx,
cancel,
});
}
}
impl Drop for SearchController {
fn drop(&mut self) {
// Dropping `index` cancels indexing too.
self.cancel();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::formats::zip::write_stored_zip;
use std::path::Path;
fn package() -> Package {
let zip = write_stored_zip(&[
("assets/a.txt", b"hello World\n indented hello\nnothing"),
("assets/b.json", br#"{"greeting":"Hello"}"#),
("assets/img.png", b"\x89PNG\r\n\x1a\nhello"),
]);
Package::from_bytes(Path::new("t.apk"), zip.into()).unwrap()
}
fn query(text: &str) -> Query {
Query {
text: text.to_owned(),
..Default::default()
}
}
#[test]
fn indexes_what_the_content_view_shows() {
let pkg = package();
let corpus = build_corpus(&pkg, &AtomicBool::new(false), &Progress::default()).unwrap();
let names: Vec<&str> = corpus
.docs
.iter()
.map(|d| pkg.nodes[d.node].label.as_str())
.collect();
// The PNG is not searchable; the JSON is indexed beautified.
assert_eq!(names, ["a.txt", "b.json"]);
assert!(corpus.docs[1].text.contains("\"greeting\": \"Hello\""));
}
#[test]
fn finds_matches_with_options() {
let pkg = package();
let corpus = build_corpus(&pkg, &AtomicBool::new(false), &Progress::default()).unwrap();
let run =
|q: Query| search(&corpus, &q.compile().unwrap(), &AtomicBool::new(false)).unwrap();
let r = run(query("hello"));
assert_eq!(r.total_matches, 3);
assert_eq!(r.files.len(), 2);
let a = &r.files[0].matches;
assert_eq!((a[0].line, a[0].range.clone()), (0, 0..5));
assert_eq!((a[1].line, a[1].range.clone()), (1, 11..16));
// Previews drop indentation and keep the match range aligned.
assert_eq!(a[1].preview, "indented hello");
assert_eq!(&a[1].preview[a[1].preview_range.clone()], "hello");
let r = run(Query {
match_case: true,
..query("Hello")
});
assert_eq!(r.total_matches, 1);
let r = run(Query {
whole_word: true,
..query("ell")
});
assert_eq!(r.total_matches, 0);
let r = run(Query {
regex: true,
..query(r"^\s+\w+")
});
assert_eq!(r.total_matches, 1);
assert!(
Query {
regex: true,
..query("(")
}
.compile()
.is_err()
);
// Without the regex option, special characters are literal.
assert_eq!(run(query("(")).total_matches, 0);
}
#[test]
fn last_line_has_no_newline() {
let doc = Doc::new(0, &["a".to_owned(), "last".to_owned()]);
assert_eq!(&doc.text[doc.line_range(1)], "last");
assert_eq!(&doc.text[doc.line_range(0)], "a");
assert_eq!(doc.line_of(doc.text.len() - 1), 1);
}
#[test]
fn previews_long_lines() {
let line = format!("{}needle{}", "a".repeat(100), "é".repeat(200));
let (text, range) = preview(&line, &(100..106));
assert!(text.starts_with('…') && text.ends_with('…'));
assert_eq!(&text[range], "needle");
}
#[test]
fn caps_results() {
let lines: Vec<String> = (0..MAX_MATCHES + 10).map(|_| "x".to_owned()).collect();
let corpus = Corpus {
docs: vec![Doc::new(0, &lines)],
};
let r = search(
&corpus,
&query("x").compile().unwrap(),
&AtomicBool::new(false),
)
.unwrap();
assert!(r.truncated);
assert_eq!(r.total_matches, MAX_MATCHES + 10);
assert_eq!(r.files[0].matches.len(), MAX_MATCHES);
}
#[test]
fn cancelling_a_search_keeps_indexing() {
let pkg = Arc::new(package());
let index = Index::start(pkg);
// A cancelled waiter returns immediately…
assert!(Index::wait(&index.state, &AtomicBool::new(true)).is_none());
// …while the index still completes for the next search.
let corpus = Index::wait(&index.state, &AtomicBool::new(false)).unwrap();
assert_eq!(corpus.docs.len(), 2);
}
#[test]
fn controller_searches_in_background() {
let pkg = Arc::new(package());
let ctx = eframe::egui::Context::default();
let mut c = SearchController::new();
c.query = query("hello");
c.search_now();
let deadline = Instant::now() + Duration::from_secs(10);
while c.results.is_none() && Instant::now() < deadline {
c.poll(&pkg, &ctx);
std::thread::sleep(Duration::from_millis(5));
}
assert_eq!(c.results.as_ref().unwrap().total_matches, 3);
assert_eq!(c.status(), Status::Idle);
// Changing the query reuses the index instead of rebuilding it.
let index_state = c.index.as_ref().map(|i| Arc::as_ptr(&i.state));
c.query = query("world");
c.search_now();
c.poll(&pkg, &ctx);
assert_eq!(c.index.as_ref().map(|i| Arc::as_ptr(&i.state)), index_state);
let deadline = Instant::now() + Duration::from_secs(10);
while c.results_query.as_ref().is_none_or(|q| q.text != "world")
&& Instant::now() < deadline
{
c.poll(&pkg, &ctx);
std::thread::sleep(Duration::from_millis(5));
}
assert_eq!(c.results.as_ref().unwrap().total_matches, 1);
c.query = Query {
regex: true,
..query("[")
};
c.search_now();
c.poll(&pkg, &ctx);
assert!(c.error.is_some() && c.results.is_none());
}
}
+321 -72
View File
@@ -1,12 +1,20 @@
//! Center panel: shows the contents of the selected node.
//! Editor card: a VS Code style tab bar with breadcrumbs, and the contents
//! of the selected node.
use std::ops::Range;
use eframe::egui::{
self, Align, Layout, RichText, ScrollArea, TextStyle, load::Bytes, text::LayoutJob,
self, CornerRadius, RichText, ScrollArea, Sense, TextStyle, load::Bytes, text::LayoutJob, vec2,
};
use super::highlight::{GUTTER, TEXT, append, highlight_into};
use super::icons::{self, Icon};
use super::theme;
use crate::inspect::{Content, Inspection, Syntax};
const TAB_BAR_HEIGHT: f32 = 35.0;
const BREADCRUMBS_HEIGHT: f32 = 22.0;
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub enum ViewMode {
/// Rendered / decoded / beautified content.
@@ -17,52 +25,232 @@ pub enum ViewMode {
Hex,
}
pub fn show(ui: &mut egui::Ui, inspection: &Inspection, mode: &mut ViewMode) {
if inspection.original.is_none() && *mode == ViewMode::Original {
/// A location to show, e.g. a search match.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Goto {
pub line: usize,
/// Byte range within the line to highlight.
pub range: Range<usize>,
/// Scroll there on the next frame.
pub scroll: bool,
}
#[derive(Default, Debug, PartialEq, Eq)]
pub struct EditorResponse {
pub close: bool,
pub toggle_info: bool,
}
/// A small text toggle in the tab bar (Formatted / Original / Hex).
fn mode_toggle(ui: &mut egui::Ui, mode: &mut ViewMode, value: ViewMode, label: &str, tip: &str) {
let selected = *mode == value;
let font = egui::FontId::proportional(12.0);
let galley = ui.painter().layout_no_wrap(
label.to_owned(),
font,
if selected {
theme::TEXT_BRIGHT
} else {
theme::TEXT_DIM
},
);
let (rect, response) = ui.allocate_exact_size(galley.size() + vec2(12.0, 6.0), Sense::click());
if selected {
ui.painter()
.rect_filled(rect, CornerRadius::same(3), theme::PILL);
} else if response.hovered() {
ui.painter()
.rect_filled(rect, CornerRadius::same(3), theme::LIST_HOVER);
}
ui.painter()
.galley(rect.center() - galley.size() / 2.0, galley, theme::TEXT);
if response.on_hover_text(tip).clicked() {
*mode = value;
}
}
/// Tab bar with a single tab for the open item, plus view actions.
fn tab_bar(
ui: &mut egui::Ui,
inspection: Option<&Inspection>,
mode: &mut ViewMode,
info_visible: bool,
shown_text: Option<&Vec<String>>,
) -> EditorResponse {
let mut response = EditorResponse::default();
let (bar, _) =
ui.allocate_exact_size(vec2(ui.available_width(), TAB_BAR_HEIGHT), Sense::hover());
let radius = theme::CARD_RADIUS;
ui.painter().rect_filled(
bar,
CornerRadius {
nw: radius,
ne: radius,
sw: 0,
se: 0,
},
theme::TAB_BAR,
);
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(bar)
.layout(egui::Layout::left_to_right(egui::Align::Center)),
);
let ui = &mut child;
if let Some(inspection) = inspection {
let name = inspection
.title
.rsplit(" / ")
.next()
.unwrap_or(&inspection.title)
.to_owned();
let font = egui::TextStyle::Body.resolve(ui.style());
let galley = ui.painter().layout_no_wrap(name, font, theme::TEXT_BRIGHT);
let tab_width = (galley.size().x + 52.0).max(120.0);
let (tab, _) = ui.allocate_exact_size(vec2(tab_width, TAB_BAR_HEIGHT), Sense::hover());
ui.painter().rect_filled(
tab,
CornerRadius {
nw: radius,
ne: 0,
sw: 0,
se: 0,
},
theme::EDITOR,
);
ui.painter().galley(
egui::pos2(tab.left() + 14.0, tab.center().y - galley.size().y / 2.0),
galley,
theme::TEXT_BRIGHT,
);
let close_rect = egui::Rect::from_center_size(
egui::pos2(tab.right() - 16.0, tab.center().y),
vec2(20.0, 20.0),
);
let mut close_ui = ui.new_child(egui::UiBuilder::new().max_rect(close_rect));
if icons::button(&mut close_ui, Icon::Close, 20.0, "Close").clicked() {
response.close = true;
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(8.0);
let tip = if info_visible {
"Hide file information (⌘I)"
} else {
"Show file information (⌘I)"
};
if icons::button(ui, Icon::PanelRight, 22.0, tip).clicked() {
response.toggle_info = true;
}
if let Some(lines) = shown_text {
if icons::button(ui, Icon::Copy, 22.0, "Copy the full text").clicked() {
ui.ctx().copy_text(lines.join("\n"));
}
}
if let Some(inspection) = inspection {
if inspection.raw.is_some() && !matches!(inspection.content, Content::Hex(_)) {
ui.add_space(6.0);
mode_toggle(ui, mode, ViewMode::Hex, "Hex", "Hex dump of the file");
if inspection.original.is_some() {
mode_toggle(
ui,
mode,
ViewMode::Original,
"Original",
"The text as stored in the package",
);
mode_toggle(
ui,
mode,
ViewMode::Preview,
"Formatted",
"Beautified; only whitespace differs from the original",
);
} else {
mode_toggle(ui, mode, ViewMode::Preview, "Preview", "Decoded content");
}
}
}
});
response
}
fn breadcrumbs(ui: &mut egui::Ui, title: &str) {
let (rect, _) = ui.allocate_exact_size(
vec2(ui.available_width(), BREADCRUMBS_HEIGHT),
Sense::hover(),
);
let font = egui::FontId::proportional(12.0);
let mut job = LayoutJob::default();
let parts: Vec<&str> = title.split(" / ").collect();
for (i, part) in parts.iter().enumerate() {
if i > 0 {
append(&mut job, " › ", theme::TEXT_DIM, &font);
}
let color = if i + 1 == parts.len() {
theme::TEXT
} else {
theme::TEXT_DIM
};
append(&mut job, part, color, &font);
}
let galley = ui.fonts(|f| f.layout_job(job));
ui.painter_at(rect).galley(
egui::pos2(rect.left() + 14.0, rect.center().y - galley.size().y / 2.0),
galley,
theme::TEXT,
);
}
/// The editor card when no item is selected.
fn watermark(ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
ui.add_space((ui.available_height() / 2.0 - 60.0).max(0.0));
for (label, keys) in [
("Select a file or class in the Explorer", ""),
("Search file contents", "⇧⌘F"),
("Export the selected item", "⌘E"),
("Toggle file information", "⌘I"),
] {
ui.label(RichText::new(format!("{label} {keys}")).color(theme::TEXT_DIM));
ui.add_space(6.0);
}
});
}
/// Shows the editor card's contents.
pub fn show(
ui: &mut egui::Ui,
inspection: Option<&Inspection>,
mode: &mut ViewMode,
goto: Option<&mut Goto>,
info_visible: bool,
) -> EditorResponse {
if inspection.is_none_or(|i| i.original.is_none()) && *mode == ViewMode::Original {
*mode = ViewMode::Preview;
}
let shown_text = match (&inspection.content, &inspection.original, *mode) {
let shown_text = inspection.and_then(|i| match (&i.content, &i.original, *mode) {
(Content::Text { .. }, Some(original), ViewMode::Original) => Some(original),
(Content::Text { lines, .. }, _, ViewMode::Preview) => Some(lines),
_ => None,
};
ui.horizontal(|ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if inspection.raw.is_some() && !matches!(inspection.content, Content::Hex(_)) {
ui.selectable_value(mode, ViewMode::Hex, "Hex");
if inspection.original.is_some() {
ui.selectable_value(mode, ViewMode::Original, "Original")
.on_hover_text("The text as stored in the package");
ui.selectable_value(mode, ViewMode::Preview, "Formatted")
.on_hover_text("Beautified; only whitespace differs from the original");
} else {
ui.selectable_value(mode, ViewMode::Preview, "Preview");
}
}
if let Some(lines) = shown_text {
if ui
.button("Copy")
.on_hover_text("Copy the full text")
.clicked()
{
ui.ctx().copy_text(lines.join("\n"));
}
}
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
ui.add(egui::Label::new(RichText::new(&inspection.title).strong()).truncate());
});
});
});
ui.separator();
let response = tab_bar(ui, inspection, mode, info_visible, shown_text);
let Some(inspection) = inspection else {
watermark(ui);
return response;
};
breadcrumbs(ui, &inspection.title);
let id = egui::Id::new(("content", inspection.node));
match (&inspection.content, &inspection.raw, *mode) {
(_, Some(raw), ViewMode::Hex) | (Content::Hex(raw), _, _) => hex_view(ui, id, raw),
(Content::Text { syntax, .. }, _, ViewMode::Original) if inspection.original.is_some() => {
let original = inspection.original.as_deref().unwrap_or_default();
text_view(ui, id.with("original"), original, *syntax)
text_view(ui, id.with("original"), original, *syntax, None)
}
(Content::Text { lines, syntax }, _, _) => text_view(ui, id, lines, *syntax),
(Content::Text { lines, syntax }, _, _) => text_view(ui, id, lines, *syntax, goto),
(Content::Image { uri, bytes }, _, _) => {
ScrollArea::both()
.id_salt(id)
@@ -79,69 +267,129 @@ pub fn show(ui: &mut egui::Ui, inspection: &Inspection, mode: &mut ViewMode) {
}
(Content::Message(msg), _, _) => {
ui.centered_and_justified(|ui| {
ui.label(RichText::new(msg).color(ui.visuals().weak_text_color()));
ui.label(RichText::new(msg).color(theme::TEXT_DIM));
});
}
}
response
}
/// Gives `range` of the job's text a background color.
fn highlight_range(job: &mut LayoutJob, range: &Range<usize>, color: egui::Color32) {
let mut sections = Vec::with_capacity(job.sections.len() + 2);
for section in job.sections.drain(..) {
let r = section.byte_range.clone();
let cut_start = range.start.clamp(r.start, r.end);
let cut_end = range.end.clamp(r.start, r.end);
for (part, highlighted) in [
(r.start..cut_start, false),
(cut_start..cut_end, true),
(cut_end..r.end, false),
] {
if part.is_empty() {
continue;
}
let mut s = section.clone();
s.byte_range = part;
if highlighted {
s.format.background = color;
}
sections.push(s);
}
}
job.sections = sections;
}
/// Shows `count` virtualized monospace rows. Each row has a gutter (line
/// number / offset) that sticks to the left edge while the content scrolls
/// horizontally underneath it.
/// horizontally underneath it. `goto` highlights (and once scrolls to) a line.
fn monospace_rows(
ui: &mut egui::Ui,
id: egui::Id,
count: usize,
gutter_chars: usize,
goto: Option<&mut Goto>,
mut row: impl FnMut(usize, &egui::FontId) -> (String, LayoutJob),
) {
let font = TextStyle::Monospace.resolve(ui.style());
let row_height = ui.fonts(|f| f.row_height(&font));
let char_width = ui.fonts(|f| f.glyph_width(&font, '0'));
let gutter_width = gutter_chars as f32 * char_width + 2.0 * GUTTER_PADDING;
let target = goto.as_ref().map(|g| (g.line, g.range.clone()));
let mut area = ScrollArea::both().id_salt(id).auto_shrink(false);
if let Some(goto) = goto {
if goto.scroll {
goto.scroll = false;
let top = goto.line as f32 * row_height - ui.available_height() / 3.0;
area = area.vertical_scroll_offset(top.max(0.0));
let column = goto.range.start as f32 * char_width;
let left = column - (ui.available_width() - gutter_width) / 3.0;
area = area.horizontal_scroll_offset(left.max(0.0));
}
}
ui.scope(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0);
ScrollArea::both().id_salt(id).auto_shrink(false).show_rows(
ui,
row_height,
count,
|ui, range| {
let viewport_left = ui.clip_rect().left();
let background = ui.visuals().panel_fill;
for i in range {
let (gutter, job) = row(i, &font);
let line = ui
.horizontal(|ui| {
ui.add_space(gutter_width);
ui.add(egui::Label::new(job).extend());
})
.response
.rect;
// Painted after the text, so scrolled text slides under it.
let rect = egui::Rect::from_min_size(
egui::pos2(viewport_left, line.top()),
egui::vec2(gutter_width, line.height().max(row_height)),
);
let painter = ui.painter();
painter.rect_filled(rect, 0.0, background);
painter.text(
egui::pos2(rect.right() - GUTTER_PADDING, rect.top()),
egui::Align2::RIGHT_TOP,
gutter,
font.clone(),
GUTTER,
area.show_rows(ui, row_height, count, |ui, range| {
let viewport = ui.clip_rect();
for i in range {
let (gutter, mut job) = row(i, &font);
let target_range = target
.as_ref()
.filter(|(line, _)| *line == i)
.map(|(_, r)| r);
if let Some(r) = target_range {
highlight_range(&mut job, r, theme::CURRENT_MATCH);
}
let background = ui.painter().add(egui::Shape::Noop);
let line = ui
.horizontal(|ui| {
ui.add_space(gutter_width);
ui.add(egui::Label::new(job).extend());
})
.response
.rect;
let full = egui::Rect::from_min_max(
egui::pos2(viewport.left(), line.top()),
egui::pos2(viewport.right(), line.top() + line.height().max(row_height)),
);
if target_range.is_some() {
ui.painter().set(
background,
egui::Shape::rect_filled(full, 0.0, theme::LINE_HIGHLIGHT),
);
}
},
);
// Painted after the text, so scrolled text slides under it.
let gutter_rect =
egui::Rect::from_min_size(full.min, egui::vec2(gutter_width, full.height()));
let painter = ui.painter();
painter.rect_filled(gutter_rect, 0.0, theme::EDITOR);
painter.text(
egui::pos2(gutter_rect.right() - GUTTER_PADDING, gutter_rect.top()),
egui::Align2::RIGHT_TOP,
gutter,
font.clone(),
if target_range.is_some() {
theme::TEXT
} else {
GUTTER
},
);
}
});
});
}
const GUTTER_PADDING: f32 = 8.0;
fn text_view(ui: &mut egui::Ui, id: egui::Id, lines: &[String], syntax: Syntax) {
fn text_view(
ui: &mut egui::Ui,
id: egui::Id,
lines: &[String],
syntax: Syntax,
goto: Option<&mut Goto>,
) {
let digits = lines.len().max(1).to_string().len();
monospace_rows(ui, id, lines.len(), digits, |i, font| {
monospace_rows(ui, id, lines.len(), digits, goto, |i, font| {
let mut job = LayoutJob::default();
highlight_into(&mut job, &lines[i], syntax, font);
((i + 1).to_string(), job)
@@ -151,7 +399,7 @@ fn text_view(ui: &mut egui::Ui, id: egui::Id, lines: &[String], syntax: Syntax)
fn hex_view(ui: &mut egui::Ui, id: egui::Id, data: &[u8]) {
const ASCII: egui::Color32 = egui::Color32::from_rgb(0xce, 0x91, 0x78);
let rows = data.len().div_ceil(16);
monospace_rows(ui, id.with("hex"), rows, 8, |row, font| {
monospace_rows(ui, id.with("hex"), rows, 8, None, |row, font| {
let start = row * 16;
let chunk = &data[start..(start + 16).min(data.len())];
let mut hex = String::with_capacity(16 * 3 + 1);
@@ -218,7 +466,8 @@ mod tests {
..Default::default()
};
let output = ctx.run(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| show(ui, &inspection, &mut mode));
egui::CentralPanel::default()
.show(ctx, |ui| show(ui, Some(&inspection), &mut mode, None, true));
});
texts(&output)
};
+124
View File
@@ -0,0 +1,124 @@
//! Small line icons in the style of VS Code's codicons, drawn as vector
//! shapes (egui's fonts do not contain codicons).
use eframe::egui::{Color32, Painter, Pos2, Rect, Shape, Stroke, vec2};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Icon {
Menu,
Files,
Search,
Close,
PanelRight,
Copy,
}
fn line(painter: &Painter, points: Vec<Pos2>, stroke: Stroke) {
painter.add(Shape::line(points, stroke));
}
/// Paints `icon` centered in `rect` (designed for a 16×16 area, scaled).
pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
let s = rect.width().min(rect.height()) / 16.0;
let o = rect.center() - vec2(8.0, 8.0) * s;
let p = |x: f32, y: f32| o + vec2(x, y) * s;
let stroke = Stroke::new(1.2 * s.max(1.0), color);
match icon {
Icon::Menu => {
for y in [4.0, 8.0, 12.0] {
line(painter, vec![p(2.5, y), p(13.5, y)], stroke);
}
}
Icon::Files => {
// Back document.
line(
painter,
vec![
p(5.0, 3.5),
p(5.0, 1.5),
p(10.5, 1.5),
p(14.0, 5.0),
p(14.0, 12.5),
p(12.0, 12.5),
],
stroke,
);
// Front document with folded corner.
line(
painter,
vec![
p(2.0, 3.5),
p(8.5, 3.5),
p(12.0, 7.0),
p(12.0, 15.0),
p(2.0, 15.0),
p(2.0, 3.5),
],
stroke,
);
line(
painter,
vec![p(8.5, 3.5), p(8.5, 7.0), p(12.0, 7.0)],
stroke,
);
}
Icon::Search => {
painter.circle_stroke(p(9.5, 6.5), 4.5 * s, stroke);
line(
painter,
vec![p(6.3, 9.7), p(1.8, 14.2)],
Stroke::new(stroke.width * 1.3, color),
);
}
Icon::Close => {
line(painter, vec![p(4.0, 4.0), p(12.0, 12.0)], stroke);
line(painter, vec![p(12.0, 4.0), p(4.0, 12.0)], stroke);
}
Icon::PanelRight => {
let r = Rect::from_min_max(p(1.5, 2.5), p(14.5, 13.5));
painter.rect_stroke(r, 1.0, stroke, eframe::egui::StrokeKind::Middle);
painter.rect_filled(Rect::from_min_max(p(10.0, 2.5), p(14.5, 13.5)), 0.0, color);
}
Icon::Copy => {
painter.rect_stroke(
Rect::from_min_max(p(5.0, 5.0), p(14.0, 14.5)),
1.0,
stroke,
eframe::egui::StrokeKind::Middle,
);
line(
painter,
vec![
p(3.0, 11.0),
p(2.0, 11.0),
p(2.0, 1.5),
p(11.0, 1.5),
p(11.0, 3.0),
],
stroke,
);
}
}
}
/// A frameless icon button with VS Code's hover background.
pub fn button(
ui: &mut eframe::egui::Ui,
icon: Icon,
size: f32,
tooltip: &str,
) -> eframe::egui::Response {
let (rect, response) = ui.allocate_exact_size(vec2(size, size), eframe::egui::Sense::click());
let color = if response.hovered() {
ui.painter().rect_filled(
rect,
4.0,
Color32::from_rgba_unmultiplied(0x5a, 0x5d, 0x5e, 0x50),
);
super::theme::TEXT_BRIGHT
} else {
super::theme::TEXT
};
paint(ui.painter(), rect.shrink(size * 0.2), icon, color);
response.on_hover_text(tooltip)
}
+69 -33
View File
@@ -1,47 +1,83 @@
//! Right panel: details about the selected node.
use eframe::egui::{self, RichText, ScrollArea};
use eframe::egui::{self, CornerRadius, RichText, ScrollArea, Sense, vec2};
use super::icons::{self, Icon};
use super::theme;
use super::tree_view::paint_chevron;
use crate::inspect::Inspection;
pub fn show(ui: &mut egui::Ui, inspection: Option<&Inspection>) {
/// Shows the panel. Returns `true` if the close button was clicked.
pub fn show(ui: &mut egui::Ui, inspection: Option<&Inspection>) -> bool {
let mut close = false;
// Header: a label pill (like VS Code's "Chat") and a close button.
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(6.0);
let font = egui::FontId::proportional(12.0);
let galley = ui
.painter()
.layout_no_wrap("File information".to_owned(), font, theme::TEXT);
let (pill, _) = ui.allocate_exact_size(galley.size() + vec2(14.0, 8.0), Sense::hover());
ui.painter()
.rect_filled(pill, CornerRadius::same(4), theme::PILL);
ui.painter()
.galley(pill.center() - galley.size() / 2.0, galley, theme::TEXT);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(4.0);
close = icons::button(ui, Icon::Close, 22.0, "Hide file information (⌘I)").clicked();
});
});
ui.add_space(6.0);
ScrollArea::vertical()
.id_salt("info_scroll")
.auto_shrink(false)
.show(ui, |ui| {
let Some(inspection) = inspection else {
ui.label(
RichText::new("Select a file or class to see its details.")
.color(ui.visuals().weak_text_color()),
);
return;
};
for (i, section) in inspection.sections.iter().enumerate() {
egui::CollapsingHeader::new(
RichText::new(&section.title).family(super::theme::bold_family()),
)
.id_salt(("info_section", i, &section.title))
.default_open(true)
egui::Frame::NONE
.inner_margin(egui::Margin {
left: 8,
right: 10,
top: 0,
bottom: 8,
})
.show(ui, |ui| {
for (key, value) in &section.rows {
if !key.is_empty() {
ui.label(
RichText::new(key)
.small()
.color(ui.visuals().weak_text_color()),
);
}
let monospace = key.contains("SHA") || key == "Signature";
let text = if monospace {
RichText::new(value).monospace()
} else {
RichText::new(value)
};
ui.add(egui::Label::new(text).wrap());
ui.add_space(4.0);
let Some(inspection) = inspection else {
ui.label(
RichText::new("Select a file or class to see its details.")
.color(theme::TEXT_DIM),
);
return;
};
for (i, section) in inspection.sections.iter().enumerate() {
egui::CollapsingHeader::new(
RichText::new(section.title.to_uppercase())
.size(11.0)
.family(theme::bold_family()),
)
.id_salt(("info_section", i, &section.title))
.default_open(true)
.icon(|ui, openness, response| {
paint_chevron(ui.painter(), response.rect.center(), openness > 0.5);
})
.show(ui, |ui| {
for (key, value) in &section.rows {
if !key.is_empty() {
ui.label(RichText::new(key).size(11.5).color(theme::TEXT_DIM));
}
let monospace = key.contains("SHA") || key == "Signature";
let text = if monospace {
RichText::new(value).monospace()
} else {
RichText::new(value)
};
ui.add(egui::Label::new(text).wrap());
ui.add_space(4.0);
}
});
ui.add_space(2.0);
}
});
ui.add_space(4.0);
}
});
close
}
+2
View File
@@ -1,6 +1,8 @@
pub mod content_view;
pub mod highlight;
pub mod icon;
pub mod icons;
pub mod info_panel;
pub mod search_view;
pub mod theme;
pub mod tree_view;
+412
View File
@@ -0,0 +1,412 @@
//! Sidebar search view: a query box with VS Code's option toggles and the
//! results grouped by file.
use std::collections::HashSet;
use std::ops::Range;
use eframe::egui::{
self, Color32, CornerRadius, Key, Margin, RichText, ScrollArea, Sense, Stroke,
text::{LayoutJob, TextFormat},
vec2,
};
use super::theme;
use super::tree_view::{icon, paint_chevron};
use crate::inspect::group_digits;
use crate::package::{NodeId, Package};
use crate::search::{SearchController, Status};
pub const INPUT_ID: &str = "search_input";
const ROW_HEIGHT: f32 = 22.0;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SearchAction {
/// Open a match in the editor.
Open {
node: NodeId,
line: usize,
range: Range<usize>,
},
PickFile,
}
#[derive(Default)]
pub struct SearchViewState {
collapsed: HashSet<NodeId>,
/// The match opened last: (node, line, match start).
selected: Option<(NodeId, usize, usize)>,
}
/// An option toggle inside the search box (Aa, ab, .*).
fn toggle(ui: &mut egui::Ui, value: &mut bool, label: RichText, tooltip: &str) -> bool {
let (rect, response) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click());
let painter = ui.painter();
if *value {
painter.rect(
rect,
CornerRadius::same(3),
Color32::from_rgba_unmultiplied(0x24, 0x89, 0xdb, 0x51),
Stroke::new(1.0, theme::ACCENT),
egui::StrokeKind::Inside,
);
} else if response.hovered() {
painter.rect_filled(
rect,
CornerRadius::same(3),
Color32::from_rgba_unmultiplied(0x5a, 0x5d, 0x5e, 0x50),
);
}
let galley = ui.painter().layout_no_wrap(
label.text().to_owned(),
egui::FontId::monospace(11.0),
if *value {
theme::TEXT_BRIGHT
} else {
theme::TEXT
},
);
let pos = rect.center() - galley.size() / 2.0;
if label.text() == "ab" {
// "Match Whole Word" is drawn as an underlined "ab".
let y = pos.y + galley.size().y + 0.5;
ui.painter().line_segment(
[egui::pos2(pos.x, y), egui::pos2(pos.x + galley.size().x, y)],
Stroke::new(1.0, theme::TEXT),
);
}
ui.painter().galley(pos, galley, theme::TEXT);
let clicked = response.clicked();
if clicked {
*value = !*value;
}
response.on_hover_text(tooltip);
clicked
}
fn search_box(ui: &mut egui::Ui, ctl: &mut SearchController) {
let focused = ui.memory(|m| m.has_focus(egui::Id::new(INPUT_ID)));
egui::Frame::NONE
.fill(theme::INPUT)
.stroke(Stroke::new(
1.0,
if focused { theme::FOCUS } else { theme::INPUT },
))
.corner_radius(CornerRadius::same(2))
.inner_margin(Margin {
left: 6,
right: 2,
top: 2,
bottom: 2,
})
.outer_margin(Margin::symmetric(12, 0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let q = &mut ctl.query;
let mut changed = false;
changed |= toggle(
ui,
&mut q.regex,
RichText::new(".*"),
"Use Regular Expression",
);
changed |= toggle(
ui,
&mut q.whole_word,
RichText::new("ab"),
"Match Whole Word",
);
changed |= toggle(ui, &mut q.match_case, RichText::new("Aa"), "Match Case");
let edit = egui::TextEdit::singleline(&mut q.text)
.id(egui::Id::new(INPUT_ID))
.frame(false)
.hint_text("Search")
.desired_width(ui.available_width());
let response = ui.add(edit);
changed |= response.changed();
let enter = response.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter));
if enter {
ctl.search_now();
} else if changed {
ctl.query_changed();
}
});
});
});
}
fn status_line(ui: &mut egui::Ui, ctl: &SearchController) {
let (text, color) = if let Some(error) = &ctl.error {
(error.clone(), ui.visuals().error_fg_color)
} else {
match ctl.status() {
Status::Indexing { done, total } => (
format!(
"Indexing {} of {} items…",
group_digits(done),
group_digits(total)
),
theme::TEXT_DIM,
),
Status::Searching => ("Searching…".to_owned(), theme::TEXT_DIM),
Status::Idle => match &ctl.results {
Some(_) if ctl.query.text.is_empty() => (String::new(), theme::TEXT_DIM),
Some(r) if r.total_matches == 0 => {
("No results found.".to_owned(), theme::TEXT_DIM)
}
Some(r) => {
let mut text = format!(
"{} {} in {} {}",
group_digits(r.total_matches),
if r.total_matches == 1 {
"result"
} else {
"results"
},
group_digits(r.files.len()),
if r.files.len() == 1 { "file" } else { "files" }
);
if r.truncated {
text.push_str(&format!(
" (showing the first {})",
group_digits(crate::search::MAX_MATCHES)
));
}
(text, theme::TEXT_DIM)
}
None => (String::new(), theme::TEXT_DIM),
},
}
};
if !text.is_empty() {
ui.horizontal(|ui| {
ui.add_space(12.0);
ui.add(egui::Label::new(RichText::new(text).color(color).size(12.0)).wrap());
});
}
}
/// VS Code's thin indeterminate progress bar at the top of a view.
fn progress_bar(ui: &mut egui::Ui, active: bool) {
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 2.0), Sense::hover());
if !active {
return;
}
let t = ui.input(|i| i.time) as f32;
let width = rect.width() * 0.3;
let x = rect.left() + ((t * 0.8).fract() * (rect.width() + width)) - width;
let bar = egui::Rect::from_min_max(
egui::pos2(x.max(rect.left()), rect.top()),
egui::pos2((x + width).min(rect.right()), rect.bottom()),
);
ui.painter().rect_filled(bar, 0.0, theme::ACCENT);
ui.ctx().request_repaint();
}
enum Row {
File(usize),
Match(usize, usize),
}
fn file_row(
ui: &mut egui::Ui,
pkg: &Package,
node: NodeId,
count: usize,
open: bool,
) -> egui::Response {
let (rect, response) =
ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::click());
let painter = ui.painter_at(rect);
if response.hovered() {
painter.rect_filled(rect, 0.0, theme::LIST_HOVER);
}
let y = rect.center().y;
paint_chevron(&painter, egui::pos2(rect.left() + 16.0, y), open);
let font = egui::TextStyle::Body.resolve(ui.style());
let (glyph, color) = icon(pkg, node, false);
let mut x = rect.left() + 26.0;
let g = painter.layout_no_wrap(glyph.to_owned(), font.clone(), color);
painter.galley(egui::pos2(x, y - g.size().y / 2.0), g.clone(), color);
x += g.size().x + 6.0;
let name = painter.layout_no_wrap(pkg.nodes[node].label.clone(), font.clone(), theme::TEXT);
painter.galley(
egui::pos2(x, y - name.size().y / 2.0),
name.clone(),
theme::TEXT,
);
x += name.size().x + 8.0;
// Count badge on the right.
let badge_text = painter.layout_no_wrap(
group_digits(count),
egui::FontId::proportional(11.0),
theme::TEXT,
);
let badge = egui::Rect::from_center_size(
egui::pos2(rect.right() - 12.0 - badge_text.size().x / 2.0 - 4.0, y),
vec2(badge_text.size().x + 10.0, 16.0),
);
painter.rect_filled(
badge,
CornerRadius::same(8),
Color32::from_rgb(0x4d, 0x4d, 0x4d),
);
painter.galley(
badge.center() - badge_text.size() / 2.0,
badge_text,
theme::TEXT,
);
// Location (parent path), dimmed, between name and badge.
let path = pkg.node_path(node);
let parent = path
.rsplit_once(" / ")
.map_or("", |(p, _)| p)
.split(" / ")
.skip(1)
.collect::<Vec<_>>()
.join("/");
let room = badge.left() - 8.0 - x;
if room > 20.0 && !parent.is_empty() {
let loc = painter.layout_no_wrap(parent, egui::FontId::proportional(11.5), theme::TEXT_DIM);
let clip = egui::Rect::from_min_max(
egui::pos2(x, rect.top()),
egui::pos2(x + room, rect.bottom()),
);
ui.painter_at(clip)
.galley(egui::pos2(x, y - loc.size().y / 2.0), loc, theme::TEXT_DIM);
}
response.on_hover_text(path)
}
fn match_row(
ui: &mut egui::Ui,
preview: &str,
range: &Range<usize>,
selected: bool,
) -> egui::Response {
let (rect, response) =
ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::click());
let painter = ui.painter_at(rect);
if selected {
painter.rect_filled(rect, 0.0, theme::LIST_SELECTED);
} else if response.hovered() {
painter.rect_filled(rect, 0.0, theme::LIST_HOVER);
}
let font = egui::TextStyle::Body.resolve(ui.style());
let plain = TextFormat::simple(font.clone(), theme::TEXT);
let mut job = LayoutJob::default();
let safe = |i: usize| {
(0..=i.min(preview.len()))
.rev()
.find(|&i| preview.is_char_boundary(i))
.unwrap_or(0)
};
let (start, end) = (safe(range.start), safe(range.end));
job.append(&preview[..start], 0.0, plain.clone());
job.append(
&preview[start..end],
0.0,
TextFormat {
background: theme::MATCH,
color: theme::TEXT_BRIGHT,
..plain.clone()
},
);
job.append(&preview[end..], 0.0, plain);
let galley = ui.fonts(|f| f.layout_job(job));
painter.galley(
egui::pos2(rect.left() + 40.0, rect.center().y - galley.size().y / 2.0),
galley,
theme::TEXT,
);
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// Shows the search view. `doc` is `None` when no package is open.
pub fn show(
ui: &mut egui::Ui,
doc: Option<(&Package, &mut SearchController, &mut SearchViewState)>,
) -> Option<SearchAction> {
let busy = doc.as_ref().is_some_and(|(_, c, _)| c.is_busy());
progress_bar(ui, busy);
theme::view_title(ui, "Search");
let Some((pkg, ctl, state)) = doc else {
ui.horizontal(|ui| {
ui.add_space(12.0);
ui.label(
RichText::new("Open a package to search its contents.").color(theme::TEXT_DIM),
);
});
ui.add_space(8.0);
let mut action = None;
egui::Frame::NONE
.inner_margin(Margin::symmetric(12, 0))
.show(ui, |ui| {
if theme::primary_button(ui, "Open APK or XAPK").clicked() {
action = Some(SearchAction::PickFile);
}
});
return action;
};
search_box(ui, ctl);
ui.add_space(6.0);
status_line(ui, ctl);
ui.add_space(4.0);
let results = ctl.results.clone()?;
let mut rows = Vec::new();
for (fi, file) in results.files.iter().enumerate() {
rows.push(Row::File(fi));
if !state.collapsed.contains(&file.node) {
rows.extend((0..file.matches.len()).map(|mi| Row::Match(fi, mi)));
}
}
let mut action = None;
ui.scope(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
ScrollArea::vertical()
.id_salt("search_results")
.auto_shrink(false)
.show_rows(ui, ROW_HEIGHT, rows.len(), |ui, range| {
for row in &rows[range] {
match *row {
Row::File(fi) => {
let file = &results.files[fi];
let open = !state.collapsed.contains(&file.node);
if file_row(ui, pkg, file.node, file.matches.len(), open).clicked()
&& !state.collapsed.remove(&file.node)
{
state.collapsed.insert(file.node);
}
}
Row::Match(fi, mi) => {
let file = &results.files[fi];
let m = &file.matches[mi];
let key = (file.node, m.line, m.range.start);
let response = match_row(
ui,
&m.preview,
&m.preview_range,
state.selected == Some(key),
)
.on_hover_text(format!("Line {}", m.line + 1));
if response.clicked() {
state.selected = Some(key);
action = Some(SearchAction::Open {
node: file.node,
line: m.line,
range: m.range.clone(),
});
}
}
}
}
});
});
action
}
+133 -16
View File
@@ -1,10 +1,59 @@
use std::sync::Arc;
use eframe::egui::{self, Color32, FontData, FontDefinitions, FontFamily, FontId};
use eframe::egui::{
self, Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Margin, Stroke,
};
/// Pitch black window and panel background.
pub const BACKGROUND: Color32 = Color32::BLACK;
pub const TEXT: Color32 = Color32::from_rgb(0xd4, 0xd4, 0xd4);
// VS Code "Dark+" colors, as used by its floating-card layout.
/// Window frame: title bar and the gaps between the cards.
pub const WINDOW: Color32 = Color32::from_rgb(0x3c, 0x3c, 0x3c);
pub const BACKGROUND: Color32 = WINDOW;
pub const ACTIVITY_BAR: Color32 = Color32::from_rgb(0x33, 0x33, 0x33);
pub const ACTIVITY_ACTIVE_BG: Color32 = Color32::from_rgb(0x1e, 0x1e, 0x1e);
pub const ICON_ACTIVE: Color32 = Color32::WHITE;
pub const ICON_INACTIVE: Color32 = Color32::from_rgb(0x85, 0x85, 0x85);
pub const SIDEBAR: Color32 = Color32::from_rgb(0x25, 0x25, 0x26);
pub const EDITOR: Color32 = Color32::from_rgb(0x1e, 0x1e, 0x1e);
pub const TAB_BAR: Color32 = Color32::from_rgb(0x25, 0x25, 0x26);
pub const CARD_BORDER: Color32 = Color32::from_rgb(0x2b, 0x2b, 0x2c);
pub const SEPARATOR: Color32 = Color32::from_rgb(0x3e, 0x3e, 0x3f);
pub const TEXT: Color32 = Color32::from_rgb(0xcc, 0xcc, 0xcc);
pub const TEXT_DIM: Color32 = Color32::from_rgb(0x9d, 0x9d, 0x9d);
pub const TEXT_BRIGHT: Color32 = Color32::WHITE;
pub const LINK: Color32 = Color32::from_rgb(0x37, 0x94, 0xff);
pub const ACCENT: Color32 = Color32::from_rgb(0x00, 0x7a, 0xcc);
pub const FOCUS: Color32 = Color32::from_rgb(0x00, 0x7f, 0xd4);
pub const BUTTON: Color32 = Color32::from_rgb(0x0e, 0x63, 0x9c);
pub const BUTTON_HOVER: Color32 = Color32::from_rgb(0x11, 0x77, 0xbb);
pub const INPUT: Color32 = Color32::from_rgb(0x3c, 0x3c, 0x3c);
pub const LIST_HOVER: Color32 = Color32::from_rgb(0x2a, 0x2d, 0x2e);
pub const LIST_SELECTED: Color32 = Color32::from_rgb(0x04, 0x39, 0x5e);
pub const PILL: Color32 = Color32::from_rgb(0x37, 0x37, 0x3d);
pub const STATUS_BAR: Color32 = Color32::from_rgb(0x00, 0x7a, 0xcc);
pub const COMMAND_CENTER: Color32 = Color32::from_rgb(0x46, 0x46, 0x46);
pub const COMMAND_CENTER_BORDER: Color32 = Color32::from_rgb(0x4b, 0x4b, 0x4b);
/// Search match in result lists (`editor.findMatchHighlightBackground`).
pub const MATCH: Color32 = Color32::from_rgba_premultiplied(0x4e, 0x1f, 0x00, 0x55);
/// The match jumped to (`editor.findMatchBackground`).
pub const CURRENT_MATCH: Color32 = Color32::from_rgb(0x51, 0x5c, 0x6a);
pub const LINE_HIGHLIGHT: Color32 = Color32::from_rgb(0x2a, 0x2d, 0x2e);
pub const CARD_RADIUS: u8 = 6;
/// Gap between the cards.
pub const GAP: i8 = 2;
/// A rounded card (sidebar, editor, panel) on the window background.
pub fn card(fill: Color32) -> egui::Frame {
egui::Frame::NONE
.fill(fill)
.stroke(Stroke::new(1.0, CARD_BORDER))
.corner_radius(CornerRadius::same(CARD_RADIUS))
.outer_margin(Margin {
left: GAP,
right: GAP,
top: 0,
bottom: 2 * GAP,
})
}
const HACK_REGULAR: &[u8] = include_bytes!("../../media/fonts/Hack-Regular.ttf");
const HACK_BOLD: &[u8] = include_bytes!("../../media/fonts/Hack-Bold.ttf");
@@ -55,21 +104,88 @@ pub fn bold(size: f32) -> FontId {
pub fn apply_dark(ctx: &egui::Context) {
ctx.set_theme(egui::Theme::Dark);
ctx.style_mut_of(egui::Theme::Dark, |style| {
style.spacing.button_padding = egui::vec2(8.0, 3.0);
style.spacing.menu_margin = Margin::same(4);
let v = &mut style.visuals;
v.panel_fill = BACKGROUND;
v.window_fill = BACKGROUND;
v.faint_bg_color = Color32::from_rgb(0x0c, 0x0c, 0x0c);
// Inputs (e.g. the tree filter) stay slightly lifted off the black.
v.extreme_bg_color = Color32::from_rgb(0x1a, 0x1a, 0x1a);
// Panel separators.
v.widgets.noninteractive.bg_stroke.color = Color32::from_rgb(0x26, 0x26, 0x26);
v.override_text_color = Some(TEXT);
v.panel_fill = WINDOW;
// Menus, popups and dialogs.
v.window_fill = SIDEBAR;
v.window_stroke = Stroke::new(1.0, Color32::from_rgb(0x45, 0x45, 0x45));
v.window_corner_radius = CornerRadius::same(CARD_RADIUS);
v.menu_corner_radius = CornerRadius::same(5);
v.faint_bg_color = Color32::from_rgb(0x2a, 0x2a, 0x2a);
v.extreme_bg_color = INPUT;
v.code_bg_color = EDITOR;
v.hyperlink_color = LINK;
v.warn_fg_color = Color32::from_rgb(0xcc, 0xa7, 0x00);
v.error_fg_color = Color32::from_rgb(0xf4, 0x87, 0x71);
v.selection.bg_fill = Color32::from_rgb(0x26, 0x4f, 0x78);
// Focus outline of text fields.
v.selection.stroke = Stroke::new(1.0, FOCUS);
let radius = CornerRadius::same(2);
let w = &mut v.widgets;
w.noninteractive.bg_stroke = Stroke::new(1.0, SEPARATOR);
w.noninteractive.fg_stroke = Stroke::new(1.0, TEXT);
w.inactive.weak_bg_fill = Color32::from_rgb(0x3a, 0x3d, 0x41);
w.inactive.bg_fill = Color32::from_rgb(0x3a, 0x3d, 0x41);
w.inactive.bg_stroke = Stroke::new(1.0, INPUT);
w.inactive.fg_stroke = Stroke::new(1.0, TEXT);
w.inactive.corner_radius = radius;
w.hovered.weak_bg_fill = Color32::from_rgb(0x45, 0x49, 0x4e);
w.hovered.bg_fill = Color32::from_rgb(0x45, 0x49, 0x4e);
w.hovered.bg_stroke = Stroke::NONE;
w.hovered.fg_stroke = Stroke::new(1.0, TEXT_BRIGHT);
w.hovered.corner_radius = radius;
w.hovered.expansion = 0.0;
w.active.weak_bg_fill = LIST_SELECTED;
w.active.bg_fill = LIST_SELECTED;
w.active.bg_stroke = Stroke::NONE;
w.active.fg_stroke = Stroke::new(1.0, TEXT_BRIGHT);
w.active.corner_radius = radius;
w.active.expansion = 0.0;
w.open.weak_bg_fill = LIST_SELECTED;
w.open.corner_radius = radius;
});
}
/// A primary (blue) button as in VS Code's welcome views.
pub fn primary_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
let height = 26.0;
let (rect, response) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), height),
egui::Sense::click(),
);
let fill = if response.hovered() {
BUTTON_HOVER
} else {
BUTTON
};
ui.painter().rect_filled(rect, CornerRadius::same(2), fill);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
text,
egui::TextStyle::Body.resolve(ui.style()),
TEXT_BRIGHT,
);
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A small uppercase-free view title such as "Explorer", dimmed.
pub fn view_title(ui: &mut egui::Ui, title: &str) {
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.add_space(12.0);
ui.label(egui::RichText::new(title).color(TEXT).size(12.0));
});
ui.add_space(4.0);
}
/// Height of the area behind the macOS traffic lights, which the app paints
/// itself (see [`window_options`]).
pub const MACOS_TITLE_BAR_HEIGHT: f32 = 28.0;
pub const MACOS_TITLE_BAR_HEIGHT: f32 = 35.0;
/// Native window options for a pitch black frame. On macOS the title bar is
/// made transparent and the content extends behind it, because the system
@@ -85,8 +201,8 @@ pub fn window_options(builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
}
}
/// Windows 11: paints the caption (title bar) and border black. Earlier
/// Windows versions do not support these attributes and keep the dark frame.
/// Windows 11: paints the caption (title bar) and border in the title bar
/// color. Earlier Windows versions keep the system dark frame.
#[cfg(target_os = "windows")]
pub fn apply_black_window_frame(frame: &eframe::Frame) {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
@@ -103,7 +219,8 @@ pub fn apply_black_window_frame(frame: &eframe::Frame) {
};
let hwnd = win32.hwnd.get() as HWND;
// COLORREF is 0x00BBGGRR.
let black: u32 = 0x0000_0000;
let [r, g, b, _] = WINDOW.to_array();
let color: u32 = u32::from(r) | (u32::from(g) << 8) | (u32::from(b) << 16);
for attribute in [DWMWA_CAPTION_COLOR, DWMWA_BORDER_COLOR] {
// SAFETY: `hwnd` is the live window owned by eframe, and the pointer
// refers to a 4-byte COLORREF as the attributes require.
@@ -111,7 +228,7 @@ pub fn apply_black_window_frame(frame: &eframe::Frame) {
DwmSetWindowAttribute(
hwnd,
attribute as u32,
(&black as *const u32).cast(),
(&color as *const u32).cast(),
std::mem::size_of::<u32>() as u32,
);
}
+87 -45
View File
@@ -12,9 +12,9 @@ const ROW_HEIGHT: f32 = 22.0;
const INDENT: f32 = 12.0;
const CHEVRON_WIDTH: f32 = 16.0;
const LEFT_PADDING: f32 = 4.0;
const SELECTED: Color32 = Color32::from_rgb(0x37, 0x37, 0x3d);
const HOVERED: Color32 = Color32::from_rgb(0x2a, 0x2d, 0x2e);
const GUIDE: Color32 = Color32::from_rgb(0x40, 0x40, 0x40);
const SELECTED: Color32 = super::theme::LIST_SELECTED;
const HOVERED: Color32 = super::theme::LIST_HOVER;
const GUIDE: Color32 = Color32::from_rgb(0x58, 0x58, 0x58);
const CHEVRON: Color32 = Color32::from_rgb(0xc5, 0xc5, 0xc5);
pub const FILTER_ID: &str = "tree_filter";
@@ -105,7 +105,7 @@ fn flatten(
rows
}
fn icon(pkg: &Package, id: NodeId, open: bool) -> (&'static str, Color32) {
pub(crate) fn icon(pkg: &Package, id: NodeId, open: bool) -> (&'static str, Color32) {
const FOLDER: Color32 = Color32::from_rgb(0xdc, 0xb6, 0x7a);
const CODE: Color32 = Color32::from_rgb(0x4e, 0xc9, 0xb0);
const GRAY: Color32 = Color32::from_rgb(0xa0, 0xa0, 0xa0);
@@ -143,7 +143,7 @@ fn icon(pkg: &Package, id: NodeId, open: bool) -> (&'static str, Color32) {
}
/// Draws a `›` (closed) or `⌄` (open) chevron centered at `center`.
fn paint_chevron(painter: &egui::Painter, center: egui::Pos2, open: bool) {
pub(crate) fn paint_chevron(painter: &egui::Painter, center: egui::Pos2, open: bool) {
let points = if open {
vec![
center + vec2(-4.0, -2.0),
@@ -176,10 +176,7 @@ fn paint_row(
let text_color = ui.visuals().text_color();
let (label, label_font) = if is_root {
(
node.label.to_uppercase(),
super::theme::bold(body.size - 1.0),
)
(node.label.to_uppercase(), super::theme::bold(11.0))
} else {
(node.label.clone(), body.clone())
};
@@ -237,6 +234,76 @@ pub struct TreeResponse {
pub export: Option<(NodeId, ExportKind)>,
}
/// The filter input, styled like VS Code's inputs. Right-to-left, so the
/// text field only takes the width left after the clear button (sizing it
/// from the full width would push the panel wider on every frame); the
/// surrounding `horizontal` limits the layout to a single row.
fn filter_box(ui: &mut egui::Ui, state: &mut TreeState) {
let focused = ui.memory(|m| m.has_focus(egui::Id::new(FILTER_ID)));
egui::Frame::NONE
.fill(super::theme::INPUT)
.stroke(Stroke::new(
1.0,
if focused {
super::theme::FOCUS
} else {
super::theme::INPUT
},
))
.corner_radius(2)
.inner_margin(egui::Margin {
left: 6,
right: 2,
top: 2,
bottom: 2,
})
.outer_margin(egui::Margin::symmetric(12, 0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if !state.filter.is_empty()
&& super::icons::button(ui, super::icons::Icon::Close, 18.0, "Clear filter")
.clicked()
{
state.filter.clear();
}
let edit = egui::TextEdit::singleline(&mut state.filter)
.id(egui::Id::new(FILTER_ID))
.frame(false)
.hint_text("Filter files and classes")
.desired_width(ui.available_width());
ui.add(edit);
});
});
});
}
/// Explorer content when no package is open (VS Code's "no folder" view).
/// Returns `true` if the open button was clicked.
pub fn show_empty(ui: &mut egui::Ui) -> bool {
super::theme::view_title(ui, "Explorer");
let mut open = false;
egui::Frame::NONE
.inner_margin(egui::Margin::symmetric(12, 4))
.show(ui, |ui| {
ui.label(
egui::RichText::new("NO PACKAGE OPENED")
.size(11.0)
.family(super::theme::bold_family()),
);
ui.add_space(10.0);
ui.label("You have not opened a package yet.");
ui.add_space(8.0);
open = super::theme::primary_button(ui, "Open APK or XAPK").clicked();
ui.add_space(12.0);
ui.label(
egui::RichText::new("You can also drop an .apk or .xapk file onto this window.")
.color(super::theme::TEXT_DIM),
);
});
open
}
/// Shows the tree.
pub fn show(
ui: &mut egui::Ui,
@@ -247,36 +314,9 @@ pub fn show(
let mut new_selection = None;
let mut export_request = None;
ui.label(
egui::RichText::new("PACKAGE")
.small()
.family(super::theme::bold_family())
.color(ui.visuals().weak_text_color()),
);
ui.add_space(2.0);
// Right-to-left, so the text field only takes the width that is left after
// the clear button; sizing it from the full width would push the panel
// wider on every frame. The surrounding `horizontal` limits the layout to
// a single row (on its own it would fill the panel's whole height).
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add_enabled(
!state.filter.is_empty(),
egui::Button::new("✖").frame(false),
)
.on_hover_text("Clear filter")
.clicked()
{
state.filter.clear();
}
let edit = egui::TextEdit::singleline(&mut state.filter)
.id(egui::Id::new(FILTER_ID))
.hint_text("🔍 Filter (⌘F)")
.desired_width(ui.available_width());
ui.add(edit);
});
});
super::theme::view_title(ui, "Explorer");
filter_box(ui, state);
ui.add_space(4.0);
let expanded_snapshot = std::mem::take(&mut state.expanded);
let (rows, matches) = match state.visible_mask(pkg) {
@@ -285,13 +325,15 @@ pub fn show(
};
state.expanded = expanded_snapshot;
if let Some(matches) = matches {
ui.label(
egui::RichText::new(format!("{matches} matches"))
.small()
.color(ui.visuals().weak_text_color()),
);
ui.horizontal(|ui| {
ui.add_space(12.0);
ui.label(
egui::RichText::new(format!("{matches} matches"))
.size(12.0)
.color(super::theme::TEXT_DIM),
);
});
}
ui.add_space(2.0);
let row_height = ROW_HEIGHT;
let selected_row = selected.and_then(|s| rows.iter().position(|(id, _)| *id == s));