mirror of
https://github.com/jankammerath/BogusMips.git
synced 2026-11-07 10:15:59 -10:00
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:
1 parent
3bd28aefd1
commit
24af8645c5
14 files changed
+2556
-459
No files matched your search
Generated
+39
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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, ®ex, &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
File diff suppressed because it is too large.
Load diff
@@ -6,4 +6,5 @@ pub mod export;
|
||||
pub mod formats;
|
||||
pub mod inspect;
|
||||
pub mod package;
|
||||
pub mod search;
|
||||
pub mod ui;
|
||||
+658
@@ -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, ®ex, &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
@@ -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
@@ -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
@@ -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(§ion.title).family(super::theme::bold_family()),
|
||||
)
|
||||
.id_salt(("info_section", i, §ion.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 §ion.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, §ion.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 §ion.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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user