diff --git a/ui/backgroundsortfilterrows.h b/ui/backgroundsortfilterrows.h new file mode 100644 index 0000000000..00ad50e5f5 --- /dev/null +++ b/ui/backgroundsortfilterrows.h @@ -0,0 +1,589 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "base/assertions.h" +#include "binaryninjacore.h" + +/*! The non-template portion of `BackgroundSortFilterRows`. + Contains the background job state machine, watcher lifecycle, and abandonment protocol, + which are independent of the row type. + + \ingroup filter +*/ +class BackgroundSortFilterRowsBase +{ +public: + // Model integration points. The reset and insert hooks bracket changes to the display rows, + // matching the corresponding QAbstractItemModel methods. + struct ModelHooks + { + std::function beginResetModel; + std::function endResetModel; + std::function beginInsertRows; + std::function endInsertRows; + // Optional. Called when background filtering starts and finishes. + std::function filteringChanged; + // Optional. Called when background sorting starts and finishes. + std::function sortingChanged; + }; + + explicit BackgroundSortFilterRowsBase(ModelHooks hooks) : m_hooks(std::move(hooks)) {} + virtual ~BackgroundSortFilterRowsBase() { shutdown(); } + + BackgroundSortFilterRowsBase(const BackgroundSortFilterRowsBase&) = delete; + BackgroundSortFilterRowsBase& operator=(const BackgroundSortFilterRowsBase&) = delete; + + bool busy() const { return m_jobActive; } + bool filtering() const { return m_jobActive && m_jobKind == JobKind::Filter; } + bool sorting() const { return m_jobActive && m_jobKind == JobKind::Sort; } + +protected: + enum class JobKind + { + Filter, + Sort, + }; + + void assertOwningThread() const { BN_ASSERT(QThread::currentThread() == m_owningThread); } + + // Abandon any in-flight job, wait for its workers to notice, and destroy the watcher + // immediately so a queued finished signal cannot run a commit handler afterwards. Derived + // destructors must call this before the row storage the workers read is destroyed. + void shutdown() + { + m_filterGeneration.fetch_add(1); + m_future.waitForFinished(); + m_watcher.reset(); + } + + // Like `shutdown`, but leaves the object reusable. Returns it to the idle state and notifies + // that the abandoned job is no longer running. The job's result is discarded, not committed. + void abandonJob() + { + if (!m_jobActive) + return; + shutdown(); + m_jobActive = false; + if (m_jobKind == JobKind::Filter && m_hooks.filteringChanged) + m_hooks.filteringChanged(false); + else if (m_jobKind == JobKind::Sort && m_hooks.sortingChanged) + m_hooks.sortingChanged(false); + } + + // Registers `future` as the running job. Its completion invokes `commitJob` back on the + // owning thread. `filterGeneration` must be the value the job's workers compare against. + void beginJob(JobKind kind, uint64_t filterGeneration, QFuture future) + { + BN_ASSERT(!m_jobActive); + m_jobActive = true; + m_jobKind = kind; + m_jobFilterGeneration = filterGeneration; + m_future = std::move(future); + m_watcher = std::make_unique>(); + QObject::connect( + m_watcher.get(), &QFutureWatcherBase::finished, m_watcher.get(), [this] { finishJob(); }); + if (kind == JobKind::Filter && m_hooks.filteringChanged) + m_hooks.filteringChanged(true); + else if (kind == JobKind::Sort && m_hooks.sortingChanged) + m_hooks.sortingChanged(true); + m_watcher->setFuture(m_future); + } + + // Whether the filter changed after the current or just-finished job captured its generation. + bool filterChangedDuringJob() const { return m_jobFilterGeneration != m_filterGeneration.load(); } + + // Called on the owning thread when the registered job's future finishes. + virtual void commitJob(JobKind kind) = 0; + + ModelHooks m_hooks; + // Bumped when the filtered set becomes stale: on a filter-predicate change, and on teardown. + // Filter workers watch it to drop a superseded filter pass. Sort workers watch it too, because a + // filter change invalidates the display snapshot they are reordering, and commitSortJob reads it + // (via filterChangedDuringJob) to recognize that case and re-filter before re-sorting. + std::atomic m_filterGeneration = 0; + +private: + void finishJob() + { + // This is invoked by the watcher's finished signal so the watcher cannot be deleted + // directly here. + m_watcher.release()->deleteLater(); + m_jobActive = false; + commitJob(m_jobKind); + } + + QThread* m_owningThread = QThread::currentThread(); + uint64_t m_jobFilterGeneration = 0; + bool m_jobActive = false; + JobKind m_jobKind = JobKind::Filter; + std::unique_ptr> m_watcher; + QFuture m_future; +}; + +/*! Append-only row storage for table models that filters and sorts rows on the worker pool. + + Filtering and sorting run as QtConcurrent jobs. Changing the filter abandons a running job at + its next check and re-runs with the latest predicate, so rapid filter changes stay responsive. + Appends and sorts requested while a job is running are deferred and applied when the job + commits, allowing the job to read the rows without locking. + + All methods must be called on the thread the object was created on (the GUI thread for table + models): the job state machine and row vectors are single-thread-confined, with the worker + threads communicating back only through the watcher's queued finished signal, and reading only + the atomic generation counters and the unmutated rows. The filter predicate and comparator are + called concurrently from worker threads and must be thread-safe. + + \ingroup filter +*/ +template +class BackgroundSortFilterRows : public BackgroundSortFilterRowsBase +{ +public: + using Predicate = std::function; + using PredicateFactory = std::function; + using Comparator = std::function; + // Display entries are indices into the master rows. 32 bits suffices: a QAbstractItemModel + // addresses rows with int, so it can never display more than INT_MAX of them. + using Index = uint32_t; + + explicit BackgroundSortFilterRows(ModelHooks hooks) : BackgroundSortFilterRowsBase(std::move(hooks)) {} + + ~BackgroundSortFilterRows() override + { + // The workers read the row storage, so they must be stopped before it is destroyed. + shutdown(); + } + + // Number of rows a view should currently display. Either all rows, or the matches of the + // committed filter. + size_t displayCount() const { return m_display.size(); } + + // The row at display position `index`, resolving through the filter/sort index. + const Row& displayAt(size_t index) const + { + BN_ASSERT(index < m_display.size()); + return m_rows[m_display[index]]; + } + + // Count of all rows, ignoring any filter, including rows whose append is deferred. + size_t totalCount() const { return m_rows.size() + m_pendingRows.size(); } + + // Drop all rows and free their storage, returning to the empty state and abandoning any + // running job. The filter and sort settings are retained, so rows appended afterward are + // filtered as before. + void clear() + { + assertOwningThread(); + abandonJob(); + m_hooks.beginResetModel(); + // Assigning fresh vectors releases the capacity rather than retaining it like clear(). + m_rows = std::vector{}; + m_display = std::vector{}; + m_pendingRows = std::vector{}; + m_pendingSort.reset(); + m_maintainSortOnAppend = false; + m_hooks.endResetModel(); + } + + void append(std::vector rows) { appendRows(std::move(rows), true); } + + void setFilter(Predicate filter) + { + if (filter) + setFilterFactory([filter] { return filter; }); + else + setFilterFactory(nullptr); + } + + // Like `setFilter`, but the factory is invoked once per work chunk on the worker thread to + // create that chunk's predicate. Use this when the predicate holds state that is expensive + // to share across threads, e.g. a QRegularExpression, whose internal mutex serializes + // concurrent matches on a shared instance. + void setFilterFactory(PredicateFactory factory) + { + assertOwningThread(); + m_filterFactory = std::move(factory); + m_filter = m_filterFactory ? m_filterFactory() : Predicate(); + m_filterGeneration.fetch_add(1); + // An active job notices the generation change, finishes early, and re-applies on completion. + if (!busy()) + applyCurrentFilter(); + } + + void sort(Comparator comparator) + { + assertOwningThread(); + m_activeSort = comparator; + // Bump the sort generation so a sort already running notices and bails out promptly rather + // than sorting rows whose result this newer request will discard. + m_sortGeneration.fetch_add(1); + if (busy()) + { + m_pendingSort = std::move(comparator); + return; + } + startSortJob(std::move(comparator)); + } + +protected: + void commitJob(JobKind kind) override + { + if (kind == JobKind::Sort) + commitSortJob(); + else + commitFilterJob(); + } + +private: + void appendRows(std::vector rows, bool resortAfterAppend) + { + assertOwningThread(); + if (rows.empty()) + return; + + if (busy()) + { + m_pendingRows.insert(m_pendingRows.end(), std::make_move_iterator(rows.begin()), + std::make_move_iterator(rows.end())); + return; + } + + // Collect the master indices of the new rows that the filter accepts. An unset filter + // accepts all of them. + const Index base = static_cast(m_rows.size()); + std::vector newDisplay; + for (size_t i = 0; i < rows.size(); i++) + if (!m_filter || m_filter(rows[i])) + newDisplay.push_back(base + static_cast(i)); + + if (!newDisplay.empty()) + { + const int oldCount = static_cast(m_display.size()); + m_hooks.beginInsertRows(oldCount, oldCount + static_cast(newDisplay.size()) - 1); + } + + // Retain every row for later filter changes, even those not currently displayed. + m_rows.insert( + m_rows.end(), std::make_move_iterator(rows.begin()), std::make_move_iterator(rows.end())); + m_display.insert(m_display.end(), newDisplay.begin(), newDisplay.end()); + + if (!newDisplay.empty()) + m_hooks.endInsertRows(); + + if (resortAfterAppend && !newDisplay.empty() && m_maintainSortOnAppend && m_activeSort) + startSortJob(m_activeSort); + } + + // Parallel merge sort of `display` (indices into `rows`) by `comparator`. Returns the sorted + // indices, or nullopt if `filterGeneration` stopped matching `currentFilterGeneration` (the + // filter changed) or `sortGeneration` stopped matching `currentSortGeneration` (a newer sort was + // requested) mid-sort, meaning the result should be discarded. + static std::optional> parallelSort(std::vector display, + const std::vector& rows, const Comparator& comparator, uint64_t filterGeneration, + const std::atomic& currentFilterGeneration, uint64_t sortGeneration, + const std::atomic& currentSortGeneration) + { + if (display.size() < 2) + return display; + + struct Abandoned{}; + std::atomic abandoned = false; + // Wraps the comparator to compare rows by index and bail out promptly if the filter or + // sort changes mid-job. + const auto guarded = [&](size_t& comparisons) { + return [&](Index a, Index b) { + if ((++comparisons & 0xfff) == 0 + && (currentFilterGeneration.load(std::memory_order_relaxed) != filterGeneration + || currentSortGeneration.load(std::memory_order_relaxed) != sortGeneration)) + throw Abandoned {}; + return comparator(rows[a], rows[b]); + }; + }; + + // Sort chunks of the indices in parallel, then merge adjacent chunks pairwise, with each + // merge round also running in parallel. A chunk is the half-open range [first, second). + const size_t threadCount = + std::clamp(QThreadPool::globalInstance()->maxThreadCount(), 1, 64); + const size_t chunkSize = std::max(1, (display.size() + threadCount - 1) / threadCount); + using Range = std::pair; + std::vector chunks; + chunks.reserve((display.size() + chunkSize - 1) / chunkSize); + for (size_t begin = 0; begin < display.size(); begin += chunkSize) + chunks.emplace_back(begin, std::min(begin + chunkSize, display.size())); + + QtConcurrent::blockingMap(chunks, [&](const Range& chunk) { + size_t comparisons = 0; + try + { + std::sort(display.begin() + chunk.first, display.begin() + chunk.second, guarded(comparisons)); + } + catch (const Abandoned&) + { + abandoned = true; + } + }); + + while (chunks.size() > 1 && !abandoned) + { + // Pair up adjacent chunks. Each pair (i, i + 1) is merged about their shared boundary + // chunks[i].second. An odd final chunk is already sorted and carries forward unchanged. + std::vector pairs; + std::vector mergedChunks; + pairs.reserve(chunks.size() / 2); + mergedChunks.reserve((chunks.size() + 1) / 2); + for (size_t i = 0; i < chunks.size(); i += 2) + { + if (i + 1 < chunks.size()) + { + pairs.push_back(i); + mergedChunks.emplace_back(chunks[i].first, chunks[i + 1].second); + } + else + { + mergedChunks.push_back(chunks[i]); + } + } + QtConcurrent::blockingMap(pairs, [&](size_t i) { + size_t comparisons = 0; + try + { + std::inplace_merge(display.begin() + chunks[i].first, display.begin() + chunks[i].second, + display.begin() + chunks[i + 1].second, guarded(comparisons)); + } + catch (const Abandoned&) + { + abandoned = true; + } + }); + chunks = std::move(mergedChunks); + } + + if (abandoned) + return std::nullopt; + return display; + } + + void startSortJob(Comparator comparator) + { + const uint64_t filterGeneration = m_filterGeneration.load(); + const uint64_t sortGeneration = m_sortGeneration.load(); + m_sortResult = std::make_shared>(); + auto result = m_sortResult; + + // The display indices are not mutated while the job is active (appends and sorts are + // deferred), so the worker sorts a copy and reads m_rows without locking. An abandoned sort + // leaves the shared result empty, which commitSortJob never reads. + auto future = QtConcurrent::run( + [this, filterGeneration, sortGeneration, comparator, result, display = m_display]() mutable { + // parallelSort spends almost all of its time blocked in QtConcurrent::blockingMap + // waiting on the same pool this driver runs on. Release this slot for the duration so + // the pool can run a replacement worker, otherwise a saturated pool could leave the + // inner map with no thread to run on. reserveThread reclaims the slot on the way out. + QThreadPool* pool = QThreadPool::globalInstance(); + pool->releaseThread(); + struct Reclaim + { + QThreadPool* pool; + ~Reclaim() { pool->reserveThread(); } + } reclaim {pool}; + + if (auto sorted = parallelSort(std::move(display), m_rows, comparator, filterGeneration, + m_filterGeneration, sortGeneration, m_sortGeneration)) + *result = std::move(*sorted); + }); + beginJob(JobKind::Sort, filterGeneration, std::move(future)); + } + + void commitSortJob() + { + auto result = std::move(m_sortResult); + + // The filter changed while sorting. Re-apply it and queue the sort to run afterwards. + if (filterChangedDuringJob()) + { + if (m_hooks.sortingChanged) + m_hooks.sortingChanged(false); + applyCurrentFilter(); + return; + } + + // A newer sort was requested while this one ran. Skip straight to it. + if (m_pendingSort) + { + auto next = std::move(*m_pendingSort); + m_pendingSort.reset(); + drainPendingRows(); + startSortJob(std::move(next)); + return; + } + + // Rows appended while this sort was running were not part of the worker's display snapshot. + // Add them and rerun the active sort over the full display set. + if (!m_pendingRows.empty()) + { + drainPendingRows(); + if (m_activeSort) + startSortJob(m_activeSort); + else if (m_hooks.sortingChanged) + m_hooks.sortingChanged(false); + return; + } + + m_hooks.beginResetModel(); + m_display = std::move(*result); + // Only start maintaining the sort across future appends once a sort has committed over + // actual rows. This keeps the initial bulk load cheap: the sort indicator is set on the + // empty model and rows stream in arrival order without re-sorting each batch, until the + // caller issues the final sort over the complete content. + m_maintainSortOnAppend = m_activeSort && !m_rows.empty(); + m_hooks.endResetModel(); + + if (m_hooks.sortingChanged) + m_hooks.sortingChanged(false); + } + + void applyCurrentFilter() + { + drainPendingRows(); + + if (!m_filter) + { + m_hooks.beginResetModel(); + // With no filter the display is every row, in master order. + m_display.resize(m_rows.size()); + std::iota(m_display.begin(), m_display.end(), Index{0}); + m_hooks.endResetModel(); + if (m_hooks.filteringChanged) + m_hooks.filteringChanged(false); + applySortAfterDisplayChange(); + return; + } + + const uint64_t filterGeneration = m_filterGeneration.load(); + PredicateFactory factory = m_filterFactory; + + constexpr size_t chunkSize = 1024 * 1024; + std::vector> chunks; + for (size_t begin = 0; begin < m_rows.size(); begin += chunkSize) + chunks.emplace_back(begin, std::min(begin + chunkSize, m_rows.size())); + + m_filterResult = QtConcurrent::mapped(chunks, + [this, filterGeneration, factory](const std::pair& chunk) { + std::vector matches; + if (m_filterGeneration.load() != filterGeneration) + return matches; + // Materialize the chunk's own predicate so predicate state is never shared across + // worker threads. + const Predicate filter = factory(); + for (size_t i = chunk.first; i < chunk.second; i++) + { + // Bail out promptly if the filter changes mid-chunk. + if ((i & 0xfff) == 0 && m_filterGeneration.load() != filterGeneration) + return matches; + if (filter(m_rows[i])) + matches.push_back(static_cast(i)); + } + return matches; + }); + beginJob(JobKind::Filter, filterGeneration, m_filterResult); + } + + void commitFilterJob() + { + auto result = std::move(m_filterResult); + + // The filter changed while the job ran. Run it again with the current predicate. + if (filterChangedDuringJob()) + { + applyCurrentFilter(); + return; + } + + std::vector matches; + for (const auto& chunkMatches : result.results()) + matches.insert(matches.end(), chunkMatches.begin(), chunkMatches.end()); + + m_hooks.beginResetModel(); + m_display = std::move(matches); + m_hooks.endResetModel(); + + drainPendingRows(); + if (m_hooks.filteringChanged) + m_hooks.filteringChanged(false); + applySortAfterDisplayChange(); + } + + void drainPendingRows() + { + if (m_pendingRows.empty()) + return; + auto pending = std::move(m_pendingRows); + m_pendingRows = {}; + appendRows(std::move(pending), false); + } + + void applyPendingSort() + { + if (!m_pendingSort) + return; + auto comparator = std::move(*m_pendingSort); + m_pendingSort.reset(); + sort(std::move(comparator)); + } + + void applySortAfterDisplayChange() + { + if (m_pendingSort) + { + applyPendingSort(); + return; + } + if (m_activeSort) + startSortJob(m_activeSort); + } + + PredicateFactory m_filterFactory; + // Materialized from m_filterFactory for matching appended rows on the main thread. + Predicate m_filter; + + // The master copy of every row, append-only and never reordered. + std::vector m_rows; + // Indices into m_rows that are currently displayed, in display order. This is the only thing + // filtering and sorting rearrange, so neither copies the rows. + std::vector m_display; + + // The sort job's output indices, read by its commit handler. Shared with the worker so the + // future's own result store, which would be a second copy, is avoided. + std::shared_ptr> m_sortResult; + // The filter job's future, whose per-chunk results its commit handler concatenates. + QFuture> m_filterResult; + + Comparator m_activeSort; + // Bumped on every sort request and watched only by sort workers, so a running sort bails out + // when a newer comparator arrives. Kept separate from m_filterGeneration so commitSortJob can + // tell a superseded sort (skip straight to the new comparator) apart from a filter change (which + // must re-filter first); one shared counter would force a full re-filter on every column re-sort. + std::atomic m_sortGeneration = 0; + std::vector m_pendingRows; + std::optional m_pendingSort; + // Set once a sort commits over a non-empty row set, after which appended rows re-run the sort to + // keep the display ordered. Left false during the initial bulk load so streamed rows append in + // arrival order rather than triggering a sort per batch. + bool m_maintainSortOnAppend = false; +}; diff --git a/view/sharedcache/api/python/sharedcache.py b/view/sharedcache/api/python/sharedcache.py index cac4050e4b..bf84cd616d 100644 --- a/view/sharedcache/api/python/sharedcache.py +++ b/view/sharedcache/api/python/sharedcache.py @@ -15,7 +15,8 @@ class CacheRegion: name: str start: int size: int - image_start: int + # Header address of the owning image, or None if the region is not part of an image. + image_start: Optional[int] # TODO: Might want to make this use the BN segment flag enum? flags: sccore.SegmentFlagEnum @@ -56,7 +57,7 @@ def region_from_api(region: sccore.BNSharedCacheRegion) -> CacheRegion: name=region.name, start=region.vmAddress, size=region.size, - image_start=region.imageStart, + image_start=region.imageStart or None, flags=region.flags ) @@ -66,7 +67,7 @@ def region_to_api(region: CacheRegion) -> sccore.BNSharedCacheRegion: _name=BNAllocString(region.name), vmAddress=region.start, size=region.size, - imageStart=region.image_start, + imageStart=region.image_start or 0, flags=region.flags ) @@ -92,6 +93,72 @@ def image_to_api(image: CacheImage) -> sccore.BNSharedCacheImage: regionStarts=core_region_starts ) +@dataclasses.dataclass +class CacheString: + string_type: sccore.StringTypeEnum + address: int + raw_length: int + text: str + region_start: int + # Header address of the owning image, or None if the string is not part of an image. + image_start: Optional[int] + + def __str__(self): + return repr(self) + + def __repr__(self): + return f"" + + +def string_from_api(string: sccore.BNSharedCacheString) -> CacheString: + return CacheString( + string_type=string.stringType, + address=string.address, + raw_length=string.rawLength, + text=string.text, + region_start=string.regionStart, + image_start=string.imageStart or None + ) + + +class CacheStringScanner: + def __init__(self, handle: sccore.BNSharedCacheStringScannerHandle): + self.handle = handle + + def __del__(self): + if self.handle is not None: + sccore.BNFreeSharedCacheStringScanner(self.handle) + + def start(self) -> bool: + return sccore.BNSharedCacheStringScannerStart(self.handle) + + @property + def is_complete(self) -> bool: + return sccore.BNSharedCacheStringScannerIsComplete(self.handle) + + @property + def progress(self) -> (int, int): + current = ctypes.c_ulonglong() + total = ctypes.c_ulonglong() + sccore.BNSharedCacheStringScannerGetProgress(self.handle, current, total) + return current.value, total.value + + @property + def string_count(self) -> int: + return sccore.BNSharedCacheStringScannerGetStringCount(self.handle) + + def take_strings(self, max_count: int = 0xffffffffffffffff) -> [CacheString]: + count = ctypes.c_ulonglong() + value = sccore.BNSharedCacheStringScannerTakeStrings(self.handle, max_count, count) + if value is None: + return [] + result = [] + for i in range(count.value): + result.append(string_from_api(value[i])) + sccore.BNSharedCacheFreeStringList(value, count) + return result + + def symbol_from_api(symbol: sccore.BNSharedCacheSymbol) -> CacheSymbol: return CacheSymbol( symbol_type=symbol.symbolType, @@ -291,6 +358,9 @@ def symbols(self) -> [CacheSymbol]: sccore.BNSharedCacheFreeSymbolList(value, count) return result + def create_string_scanner(self) -> CacheStringScanner: + return CacheStringScanner(sccore.BNSharedCacheControllerCreateStringScanner(self.handle)) + def _get_shared_cache(instance: binaryninja.PythonScriptingInstance): if instance.interpreter.active_view is None: diff --git a/view/sharedcache/api/python/sharedcache_enums.py b/view/sharedcache/api/python/sharedcache_enums.py index d772adda31..1dd767d52b 100644 --- a/view/sharedcache/api/python/sharedcache_enums.py +++ b/view/sharedcache/api/python/sharedcache_enums.py @@ -26,6 +26,13 @@ class SharedCacheRegionType(enum.IntEnum): SharedCacheRegionTypeNonImage = 3 +class StringType(enum.IntEnum): + AsciiString = 0 + Utf16String = 1 + Utf32String = 2 + Utf8String = 3 + + class SymbolBinding(enum.IntEnum): NoBinding = 0 LocalBinding = 1 diff --git a/view/sharedcache/api/sharedcache.cpp b/view/sharedcache/api/sharedcache.cpp index d90f27994b..ba4d85aa69 100644 --- a/view/sharedcache/api/sharedcache.cpp +++ b/view/sharedcache/api/sharedcache.cpp @@ -53,6 +53,9 @@ CacheRegion RegionFromApi(BNSharedCacheRegion apiRegion) region.size = apiRegion.size; region.flags = apiRegion.flags; region.type = apiRegion.regionType; + // A zeroed imageStart means the region is not associated with an image. + if (apiRegion.imageStart != 0) + region.imageStart = apiRegion.imageStart; return region; } @@ -111,6 +114,18 @@ CacheSymbol SymbolFromApi(BNSharedCacheSymbol apiSymbol) return symbol; } +CacheString StringFromApi(const BNSharedCacheString& apiString) +{ + CacheString string; + string.type = apiString.stringType; + string.address = apiString.address; + string.rawLength = static_cast(apiString.rawLength); + string.text = apiString.text; + string.regionStart = apiString.regionStart; + string.imageStart = apiString.imageStart; + return string; +} + std::string SharedCacheAPI::GetRegionTypeAsString(const BNSharedCacheRegionType &type) { switch (type) @@ -359,3 +374,77 @@ std::vector SharedCacheController::GetSymbols() const BNSharedCacheFreeSymbolList(symbols, count); return result; } + +CacheStringScanner::CacheStringScanner(BNSharedCacheStringScanner* scanner) : m_object(scanner) {} + + +CacheStringScanner::~CacheStringScanner() +{ + if (m_object) + BNFreeSharedCacheStringScanner(m_object); +} + + +CacheStringScanner::CacheStringScanner(CacheStringScanner&& other) noexcept : m_object(other.m_object) +{ + other.m_object = nullptr; +} + + +CacheStringScanner& CacheStringScanner::operator=(CacheStringScanner&& other) noexcept +{ + if (this != &other) + { + if (m_object) + BNFreeSharedCacheStringScanner(m_object); + m_object = other.m_object; + other.m_object = nullptr; + } + return *this; +} + + +bool CacheStringScanner::Start() +{ + return BNSharedCacheStringScannerStart(m_object); +} + + +bool CacheStringScanner::IsComplete() const +{ + return BNSharedCacheStringScannerIsComplete(m_object); +} + + +std::pair CacheStringScanner::GetProgress() const +{ + uint64_t current = 0; + uint64_t total = 0; + BNSharedCacheStringScannerGetProgress(m_object, ¤t, &total); + return {current, total}; +} + + +uint64_t CacheStringScanner::GetStringCount() const +{ + return BNSharedCacheStringScannerGetStringCount(m_object); +} + + +std::vector CacheStringScanner::TakeStrings(uint64_t maxCount) +{ + size_t count; + BNSharedCacheString* strings = BNSharedCacheStringScannerTakeStrings(m_object, maxCount, &count); + std::vector result; + result.reserve(count); + for (size_t i = 0; i < count; i++) + result.emplace_back(StringFromApi(strings[i])); + BNSharedCacheFreeStringList(strings, count); + return result; +} + + +std::unique_ptr SharedCacheController::CreateStringScanner() const +{ + return std::make_unique(BNSharedCacheControllerCreateStringScanner(m_object)); +} diff --git a/view/sharedcache/api/sharedcacheapi.h b/view/sharedcache/api/sharedcacheapi.h index 070b10f630..dc80b98832 100644 --- a/view/sharedcache/api/sharedcacheapi.h +++ b/view/sharedcache/api/sharedcacheapi.h @@ -3,6 +3,8 @@ #include #include "sharedcachecore.h" +#include + template class DSCRefCountObject { void AddRefInternal() { m_refs.fetch_add(1); } @@ -296,6 +298,39 @@ namespace SharedCacheAPI { std::string GetSymbolTypeAsString(const BNSymbolType& type); + struct CacheString + { + // UTF-8 display text, truncated. + std::string text; + uint64_t address; + uint64_t regionStart; + // Header address of the image owning the string's region, or 0 if it is not part of an image. + uint64_t imageStart; + BNStringType type; + // Length of the string in the cache, in bytes. + uint32_t rawLength; + }; + + class CacheStringScanner + { + BNSharedCacheStringScanner* m_object = nullptr; + + public: + explicit CacheStringScanner(BNSharedCacheStringScanner* scanner); + ~CacheStringScanner(); + + CacheStringScanner(const CacheStringScanner&) = delete; + CacheStringScanner& operator=(const CacheStringScanner&) = delete; + CacheStringScanner(CacheStringScanner&& other) noexcept; + CacheStringScanner& operator=(CacheStringScanner&& other) noexcept; + + bool Start(); + bool IsComplete() const; + std::pair GetProgress() const; + uint64_t GetStringCount() const; + std::vector TakeStrings(uint64_t maxCount); + }; + class SharedCacheController : public DSCCoreRefCountObject { public: explicit SharedCacheController(BNSharedCacheController* controller); @@ -330,5 +365,7 @@ namespace SharedCacheAPI { std::vector GetImages() const; std::vector GetLoadedImages() const; std::vector GetSymbols() const; + + std::unique_ptr CreateStringScanner() const; }; } diff --git a/view/sharedcache/api/sharedcachecore.h b/view/sharedcache/api/sharedcachecore.h index f8e9c48bcc..fcfb8d062c 100644 --- a/view/sharedcache/api/sharedcachecore.h +++ b/view/sharedcache/api/sharedcachecore.h @@ -61,10 +61,19 @@ extern "C" GlobalBinding = 2, WeakBinding = 3, }; + + enum BNStringType : uint8_t + { + AsciiString = 0, + Utf16String = 1, + Utf32String = 2, + Utf8String = 3, + }; #endif typedef struct BNBinaryView BNBinaryView; typedef struct BNSharedCacheController BNSharedCacheController; + typedef struct BNSharedCacheStringScanner BNSharedCacheStringScanner; typedef enum BNSharedCacheEntryType { SharedCacheEntryTypePrimary, @@ -119,6 +128,18 @@ extern "C" char* name; } BNSharedCacheSymbol; + typedef struct BNSharedCacheString { + BNStringType stringType; + uint64_t address; + // Length of the string in the cache, in bytes. + size_t rawLength; + // UTF-8 display text, truncated. + char* text; + uint64_t regionStart; + // NOTE: If not associated with an image this will be zero. + uint64_t imageStart; + } BNSharedCacheString; + SHAREDCACHE_FFI_API BNSharedCacheController* BNGetSharedCacheController(BNBinaryView* data); SHAREDCACHE_FFI_API BNSharedCacheController* BNNewSharedCacheControllerReference(BNSharedCacheController* controller); @@ -166,6 +187,22 @@ extern "C" SHAREDCACHE_FFI_API void BNSharedCacheFreeEntry(BNSharedCacheEntry entry); SHAREDCACHE_FFI_API void BNSharedCacheFreeEntryList(BNSharedCacheEntry* entries, size_t count); + SHAREDCACHE_FFI_API BNSharedCacheStringScanner* BNSharedCacheControllerCreateStringScanner( + BNSharedCacheController* controller); + SHAREDCACHE_FFI_API void BNFreeSharedCacheStringScanner(BNSharedCacheStringScanner* scanner); + SHAREDCACHE_FFI_API bool BNSharedCacheStringScannerStart(BNSharedCacheStringScanner* scanner); + SHAREDCACHE_FFI_API bool BNSharedCacheStringScannerIsComplete(BNSharedCacheStringScanner* scanner); + SHAREDCACHE_FFI_API void BNSharedCacheStringScannerGetProgress( + BNSharedCacheStringScanner* scanner, uint64_t* current, uint64_t* total); + + SHAREDCACHE_FFI_API uint64_t BNSharedCacheStringScannerGetStringCount(BNSharedCacheStringScanner* scanner); + // Removes and returns up to maxCount of the queued scan results. + SHAREDCACHE_FFI_API BNSharedCacheString* BNSharedCacheStringScannerTakeStrings( + BNSharedCacheStringScanner* scanner, uint64_t maxCount, size_t* count); + + SHAREDCACHE_FFI_API void BNSharedCacheFreeString(BNSharedCacheString string); + SHAREDCACHE_FFI_API void BNSharedCacheFreeStringList(BNSharedCacheString* strings, size_t count); + #ifdef __cplusplus } diff --git a/view/sharedcache/core/CacheStringScanner.cpp b/view/sharedcache/core/CacheStringScanner.cpp new file mode 100644 index 0000000000..0c263ff346 --- /dev/null +++ b/view/sharedcache/core/CacheStringScanner.cpp @@ -0,0 +1,193 @@ +#include "CacheStringScanner.h" + +#include "base/unicode.h" +#include "binaryninjaapi.h" + +using namespace BinaryNinja; +using namespace BinaryNinja::DSC; + +namespace { + +constexpr size_t kChunkSize = 1024 * 1024; + +// Builds the UTF-8 display text for a string found at `data`, truncated to +// `CacheStringScanner::kMaxDisplayTextLength` bytes of output. +std::string DisplayTextForString(const uint8_t* data, const BNStringReference& ref) +{ + constexpr size_t maxLength = CacheStringScanner::kMaxDisplayTextLength; + const std::span bytes(data, ref.length); + switch (ref.type) + { + case AsciiString: + case Utf8String: + return bn::base::TruncateUTF8(bytes, maxLength); + case Utf16String: + return bn::base::UTF16ToUTF8(bytes, maxLength); + case Utf32String: + return bn::base::UTF32ToUTF8(bytes, maxLength); + } + return {}; +} + +} // namespace + +CacheStringScanner::CacheStringScanner(SharedCache& cache, std::regex regionFilter, Ref logger) + : m_cache(cache), m_regionFilter(std::move(regionFilter)), m_logger(std::move(logger)) +{} + +CacheStringScanner::~CacheStringScanner() +{ + // Tell any outstanding scan jobs to bail out. We cannot block waiting for them to + // drain as it would risk a deadlock during exit. + if (m_state) + m_state->abort = true; +} + +bool CacheStringScanner::Start() +{ + if (m_started.exchange(true)) + return false; + + auto state = std::make_shared(); + state->vm = m_cache.GetVirtualMemory(); + state->logger = m_logger; + state->detector.emplace(StringDetectionParameters::FromSettings(Settings::Instance())); + + uint64_t totalBytes = 0; + for (const auto& [range, region] : m_cache.GetRegions()) + { + // Stub islands hold only trampoline code, and the filtered regions (LINKEDIT by default) + // hold symbol tables whose name strings are already presented by the symbols UI. + if (region.type == CacheRegionType::StubIsland) + continue; + if (std::regex_match(region.name, m_regionFilter)) + continue; + state->regions.push_back(region); + totalBytes += region.size; + } + state->bytesTotal = totalBytes; + m_state = state; + + if (state->regions.empty()) + { + state->complete = true; + return true; + } + + { + std::lock_guard lock(state->completionMutex); + state->remainingRegions = state->regions.size(); + } + for (size_t i = 0; i < state->regions.size(); i++) + { + // Scanning starts only when the user opens the Strings tab and is watching it fill, so it + // runs at priority above the normal-priority background analysis of off-screen code. + WorkerPriorityEnqueue([state, i] { + ScanRegion(*state, state->regions[i]); + FinishRegion(*state); + }, "Scanning shared cache strings"); + } + return true; +} + +void CacheStringScanner::FinishRegion(ScanState& state) +{ + std::lock_guard lock(state.completionMutex); + if (--state.remainingRegions == 0) + state.complete = true; +} + +void CacheStringScanner::GetProgress(uint64_t& current, uint64_t& total) const +{ + if (!m_state) + { + current = 0; + total = 0; + return; + } + current = m_state->bytesScanned.load(); + total = m_state->bytesTotal.load(); +} + +size_t CacheStringScanner::GetStringCount() const +{ + if (!m_state) + return 0; + std::lock_guard lock(m_state->resultsMutex); + return m_state->producedCount; +} + +std::vector CacheStringScanner::TakeStrings(size_t maxCount) +{ + if (!m_state) + return {}; + auto& strings = m_state->strings; + std::lock_guard lock(m_state->resultsMutex); + const size_t count = std::min(maxCount, strings.size()); + const size_t first = strings.size() - count; + std::vector result( + std::make_move_iterator(strings.begin() + first), std::make_move_iterator(strings.end())); + strings.erase(strings.begin() + first, strings.end()); + + // Shrink the buffer if it both proportionally and substantially larger than the live data. + static constexpr size_t kMinReclaimableSlack = 1 << 20; + if (strings.capacity() > 2 * strings.size() + kMinReclaimableSlack) + strings.shrink_to_fit(); + + return result; +} + +void CacheStringScanner::ScanRegion(ScanState& state, const CacheRegion& region) +{ + const uint64_t imageStart = region.imageStart.value_or(0); + const uint64_t end = region.start + region.size; + + BNStringReference lastFound {}; + for (uint64_t cur = region.start; cur < end; ) + { + if (state.abort) + return; + + const size_t blockLen = static_cast(std::min(kChunkSize, end - cur)); + const size_t dataLen = static_cast(std::min(blockLen + BN_MAX_STRING_LENGTH, end - cur)); + + std::span data; + try + { + data = state.vm->ReadSpan(cur, dataLen); + } + catch (std::exception& e) + { + // This happens if we have not mapped in all the relevant entries. + state.logger->LogErrorF("Failed to read region {:#x} while scanning for strings: {}", region.start, e.what()); + state.bytesScanned += end - cur; + return; + } + + const auto refs = state.detector->DetectStrings(data.data(), dataLen, blockLen, cur, &lastFound); + + std::vector batch; + batch.reserve(refs.size()); + for (const auto& ref : refs) + { + CacheString str; + str.type = ref.type; + str.address = ref.start; + str.rawLength = ref.length; + str.text = DisplayTextForString(data.data() + (ref.start - cur), ref); + str.regionStart = region.start; + str.imageStart = imageStart; + batch.push_back(std::move(str)); + } + + { + std::lock_guard lock(state.resultsMutex); + state.producedCount += batch.size(); + state.strings.insert(state.strings.end(), std::make_move_iterator(batch.begin()), + std::make_move_iterator(batch.end())); + } + + state.bytesScanned += blockLen; + cur += blockLen; + } +} diff --git a/view/sharedcache/core/CacheStringScanner.h b/view/sharedcache/core/CacheStringScanner.h new file mode 100644 index 0000000000..2945497a47 --- /dev/null +++ b/view/sharedcache/core/CacheStringScanner.h @@ -0,0 +1,103 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "binaryninjaapi.h" +#include "SharedCache.h" + +namespace BinaryNinja::DSC { + + // A string found in the cache by `CacheStringScanner`. + struct CacheString + { + BNStringType type; + uint64_t address; + // Length of the string in the cache, in bytes. + size_t rawLength; + // UTF-8 display text, truncated to `CacheStringScanner::kMaxDisplayTextLength` bytes. + std::string text; + // Start address of the region containing the string. + uint64_t regionStart; + // Header address of the image owning the region, or 0 if the region is not part of an image. + uint64_t imageStart; + }; + + // Scans all mapped cache regions for strings using the same string detection as the core + // strings analysis. Each region is scanned as a separate worker pool job. Results queue in job + // completion order. Consumers drain them with `TakeStrings(maxCount)`, so the scanner only + // holds strings that have not yet been handed off. Sort once the scan completes. + class CacheStringScanner + { + public: + static constexpr size_t kMaxDisplayTextLength = 256; + + CacheStringScanner(SharedCache& cache, std::regex regionFilter, Ref logger); + ~CacheStringScanner(); + + CacheStringScanner(const CacheStringScanner&) = delete; + CacheStringScanner& operator=(const CacheStringScanner&) = delete; + + // Enqueues a scan job for each cache region. Idempotent. + // Returns false if the scan was already started. + bool Start(); + + bool IsScanStarted() const { return m_started.load(); } + bool IsScanComplete() const { return m_state && m_state->complete.load(); } + + // Progress in bytes scanned out of total bytes scheduled for scanning. + void GetProgress(uint64_t& current, uint64_t& total) const; + + // Total number of strings detected so far, including those already taken. + size_t GetStringCount() const; + + // Removes and returns up to `maxCount` of the queued strings. Result order is unspecified. + std::vector TakeStrings(size_t maxCount); + + private: + // Everything the scan jobs read and write, owned jointly by the scanner and every queued + // job so that the scanner itself can safely be destroyed while jobs are still queued or + // running. + struct ScanState + { + std::shared_ptr vm; + // The regions to scan, copied out of the cache so the jobs do not reference it. + std::vector regions; + Ref logger; + std::optional detector; + + std::atomic complete = false; + std::atomic abort = false; + + std::mutex completionMutex; + size_t remainingRegions = 0; + + std::atomic bytesScanned = 0; + std::atomic bytesTotal = 0; + + std::mutex resultsMutex; + // Detected strings not yet taken. TakeStrings drains from the back, which is O(1) per + // element. Order does not matter as the consumer sorts the full set once scanning + // completes. + std::vector strings; + // Total detected, including taken strings. Not reduced by TakeStrings. + uint64_t producedCount = 0; + }; + + static void ScanRegion(ScanState& state, const CacheRegion& region); + static void FinishRegion(ScanState& state); + + // Used only by `Start`, which copies the scan inputs into `ScanState`. + SharedCache& m_cache; + std::regex m_regionFilter; + Ref m_logger; + + std::atomic m_started = false; + std::shared_ptr m_state; + }; + +} // namespace BinaryNinja::DSC diff --git a/view/sharedcache/core/SharedCacheController.cpp b/view/sharedcache/core/SharedCacheController.cpp index 8ed011bd39..e8bd52b7cc 100644 --- a/view/sharedcache/core/SharedCacheController.cpp +++ b/view/sharedcache/core/SharedCacheController.cpp @@ -289,7 +289,7 @@ void SharedCacheController::LoadMetadata(const Metadata& metadata) { const auto loadedRegions = controllerMeta["loadedRegions"]->GetUnsignedIntegerList(); for (const auto& region : loadedRegions) - m_loadedImages.insert(region); + m_loadedRegions.insert(region); } } @@ -316,3 +316,8 @@ void SharedCacheController::ProcessObjCForLoadedImages(BinaryView& view) } } } + +std::unique_ptr SharedCacheController::CreateStringScanner() +{ + return std::make_unique(m_cache, m_regionFilter, m_logger); +} diff --git a/view/sharedcache/core/SharedCacheController.h b/view/sharedcache/core/SharedCacheController.h index c468bee5ec..07c5ce7887 100644 --- a/view/sharedcache/core/SharedCacheController.h +++ b/view/sharedcache/core/SharedCacheController.h @@ -1,8 +1,10 @@ #pragma once +#include #include #include +#include "CacheStringScanner.h" #include "SharedCache.h" #include "refcountobject.h" #include "ffi_global.h" @@ -66,5 +68,7 @@ namespace BinaryNinja::DSC { // Re-run the ObjC processor for loaded images to restore Objective-C metadata. void ProcessObjCForLoadedImages(BinaryView& view); + + std::unique_ptr CreateStringScanner(); }; } // namespace BinaryNinja::DSC diff --git a/view/sharedcache/core/ffi.cpp b/view/sharedcache/core/ffi.cpp index 7ad10f9bb6..572264c73e 100644 --- a/view/sharedcache/core/ffi.cpp +++ b/view/sharedcache/core/ffi.cpp @@ -4,6 +4,12 @@ using namespace BinaryNinja; using namespace BinaryNinja::DSC; +struct BNSharedCacheStringScanner +{ + DSCRef controller; + std::unique_ptr scanner; +}; + BNSharedCacheImage ImageToApi(const CacheImage& image) { BNSharedCacheImage apiImage; @@ -82,6 +88,9 @@ CacheRegion RegionFromApi(const BNSharedCacheRegion& apiRegion) region.size = apiRegion.size; region.flags = apiRegion.flags; region.type = RegionTypeFromApi(apiRegion.regionType); + // A zeroed imageStart means the region is not associated with an image. + if (apiRegion.imageStart != 0) + region.imageStart = apiRegion.imageStart; return region; } @@ -105,6 +114,18 @@ CacheSymbol SymbolFromApi(const BNSharedCacheSymbol& apiSymbol) return symbol; } +BNSharedCacheString StringToApi(const CacheString& string) +{ + BNSharedCacheString apiString; + apiString.stringType = string.type; + apiString.address = string.address; + apiString.rawLength = string.rawLength; + apiString.text = BNAllocStringWithLength(string.text.c_str(), string.text.size()); + apiString.regionStart = string.regionStart; + apiString.imageStart = string.imageStart; + return apiString; +} + BNSharedCacheEntryType EntryTypeToApi(const CacheEntryType& entryType) { switch (entryType) @@ -431,4 +452,57 @@ extern "C" BNSharedCacheFreeEntry(entries[i]); delete[] entries; } + + BNSharedCacheStringScanner* BNSharedCacheControllerCreateStringScanner(BNSharedCacheController* controller) + { + return new BNSharedCacheStringScanner {controller->object, controller->object->CreateStringScanner()}; + } + + void BNFreeSharedCacheStringScanner(BNSharedCacheStringScanner* scanner) + { + delete scanner; + } + + bool BNSharedCacheStringScannerStart(BNSharedCacheStringScanner* scanner) + { + return scanner->scanner->Start(); + } + + bool BNSharedCacheStringScannerIsComplete(BNSharedCacheStringScanner* scanner) + { + return scanner->scanner->IsScanComplete(); + } + + void BNSharedCacheStringScannerGetProgress(BNSharedCacheStringScanner* scanner, uint64_t* current, uint64_t* total) + { + scanner->scanner->GetProgress(*current, *total); + } + + uint64_t BNSharedCacheStringScannerGetStringCount(BNSharedCacheStringScanner* scanner) + { + return scanner->scanner->GetStringCount(); + } + + BNSharedCacheString* BNSharedCacheStringScannerTakeStrings( + BNSharedCacheStringScanner* scanner, uint64_t maxCount, size_t* count) + { + const auto strings = scanner->scanner->TakeStrings(maxCount); + *count = strings.size(); + BNSharedCacheString* apiStrings = new BNSharedCacheString[*count]; + for (size_t i = 0; i < *count; i++) + apiStrings[i] = StringToApi(strings[i]); + return apiStrings; + } + + void BNSharedCacheFreeString(BNSharedCacheString string) + { + BNFreeString(string.text); + } + + void BNSharedCacheFreeStringList(BNSharedCacheString* strings, size_t count) + { + for (size_t i = 0; i < count; i++) + BNSharedCacheFreeString(strings[i]); + delete[] strings; + } }; diff --git a/view/sharedcache/ui/CMakeLists.txt b/view/sharedcache/ui/CMakeLists.txt index a0450722a6..96c24db95b 100644 --- a/view/sharedcache/ui/CMakeLists.txt +++ b/view/sharedcache/ui/CMakeLists.txt @@ -4,7 +4,7 @@ project(sharedcacheui) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) -find_package(Qt6 COMPONENTS Core Gui Widgets REQUIRED) +find_package(Qt6 COMPONENTS Core Concurrent Gui Widgets REQUIRED) file(GLOB SOURCES CONFIGURE_DEPENDS *.cpp *.h) list(FILTER SOURCES EXCLUDE REGEX moc_.*) @@ -93,6 +93,6 @@ get_recursive_include_dirs(sharedcacheapi INCLUDES) target_include_directories(sharedcacheui PRIVATE ${INCLUDES}) -target_link_libraries(sharedcacheui sharedcacheapi sharedcache binaryninjaui Qt6::Core Qt6::Gui Qt6::Widgets) +target_link_libraries(sharedcacheui sharedcacheapi sharedcache binaryninjaui Qt6::Core Qt6::Concurrent Qt6::Gui Qt6::Widgets) diff --git a/view/sharedcache/ui/addresstext.h b/view/sharedcache/ui/addresstext.h new file mode 100644 index 0000000000..2b339603b8 --- /dev/null +++ b/view/sharedcache/ui/addresstext.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +// Renders an address the way the triage tables' Address columns display it: lowercase hex, +// zero-padded to `width` digits. +inline std::string AddressText(uint64_t address, uint32_t width) +{ + std::string text(width, '0'); + for (size_t i = text.size(); address != 0 && i > 0; address >>= 4) + text[--i] = "0123456789abcdef"[address & 0xf]; + return text; +} diff --git a/view/sharedcache/ui/dsctriage.cpp b/view/sharedcache/ui/dsctriage.cpp index c4d9b643d0..c2e7d661be 100644 --- a/view/sharedcache/ui/dsctriage.cpp +++ b/view/sharedcache/ui/dsctriage.cpp @@ -1,8 +1,13 @@ +#include #include +#include #include +#include +#include #include #include "dsctriage.h" #include "globalarea.h" +#include "stringstable.h" #include "symboltable.h" #include "ui/fontsettings.h" @@ -52,8 +57,16 @@ DSCTriageView::DSCTriageView(QWidget* parent, BinaryViewRef data) : QWidget(pare QWidget* defaultWidget = initImageTable(); initSymbolTable(); + initStringsTab(); initCacheInfoTables(); + // The string scan and symbol fetch are expensive, so each panel starts its load only once its + // tab is first shown, and clears its large result set after the tab leaves the screen. + connect(m_triageTabs, &SplitTabWidget::currentChanged, this, [this](QWidget* widget) { + m_stringsPanel->setCurrentTabWidget(widget); + m_symbolsPanel->setCurrentTabWidget(widget); + }); + m_layout = new QVBoxLayout(this); m_layout->addWidget(m_triageTabs); setLayout(m_layout); @@ -73,7 +86,8 @@ DSCTriageView::~DSCTriageView() } -void DSCTriageView::loadImagesWithAddr(const std::vector& addresses, bool includeDependencies) { +void DSCTriageView::loadImagesWithAddr(const std::vector& addresses, bool includeDependencies, + std::optional navigateTo) { auto controller = SharedCacheController::GetController(*m_data); if (!controller) return; @@ -119,21 +133,26 @@ void DSCTriageView::loadImagesWithAddr(const std::vector& addresses, b // Apply the images in a future than update the triage view and run analysis. QPointer> watcher = new QFutureWatcher(this); - connect(watcher, &QFutureWatcher::finished, this, [watcher, this]() { + connect(watcher, &QFutureWatcher::finished, this, [watcher, navigateTo, this]() { if (watcher) { auto loadedImages = watcher->result(); - if (loadedImages.empty()) - return; + if (!loadedImages.empty()) + { + // Update the triage to display the images as loaded. + for (const auto& image : loadedImages) + setImageLoaded(image.headerAddress); + + // Run analysis. + this->m_data->AddAnalysisOption("linearsweep"); + this->m_data->AddAnalysisOption("pointersweep"); + this->m_data->UpdateAnalysis(); + } - // Update the triage to display the images as loaded. - for (const auto& image : loadedImages) - setImageLoaded(image.headerAddress); + if (navigateTo) + navigateToAddress(*navigateTo); - // Run analysis. - this->m_data->AddAnalysisOption("linearsweep"); - this->m_data->AddAnalysisOption("pointersweep"); - this->m_data->UpdateAnalysis(); + watcher->deleteLater(); } }); QFuture future = QtConcurrent::run([this, controller, images, imageLoadTask]() { @@ -290,6 +309,9 @@ QWidget* DSCTriageView::initImageTable() connect(loadImageFilterEdit, &FilterEdit::textChanged, [this, loadImageFilterEdit](const QString& filter) { m_imageTable->setFilter(filter.toStdString(), loadImageFilterEdit->getFilterOptions()); }); + connect(loadImageFilterEdit, &FilterEdit::optionsChanged, [this, loadImageFilterEdit](FilterOptions options) { + m_imageTable->setFilter(loadImageFilterEdit->text().toStdString(), options); + }); connect(m_imageTable, &FilterableTableView::activated, this, [=, this](const QModelIndex& index) { auto addr = m_imageModel->item(index.row(), 0)->text().toULongLong(nullptr, 16); @@ -336,81 +358,299 @@ QWidget* DSCTriageView::initImageTable() void DSCTriageView::initSymbolTable() { m_symbolTable = new SymbolTableView(this); - - // Apply custom column styling - m_symbolTable->setItemDelegateForColumn(0, new AddressColorDelegate(m_symbolTable)); - - auto symbolFilterEdit = new FilterEdit(m_symbolTable); - symbolFilterEdit->setPlaceholderText("Filter symbols"); - connect(symbolFilterEdit, &FilterEdit::textChanged, [this, symbolFilterEdit](const QString& filter) { - m_symbolTable->setFilter(filter.toStdString(), symbolFilterEdit->getFilterOptions()); + m_symbolsPanel = new TriageTablePanel(this, m_symbolTable, "Filter symbols", "symbols"); + m_symbolsPanel->setLoader([this] { return startSymbolLoad(); }); + m_symbolsPanel->setClearHandler([this] { + // Discard an in-flight symbol fetch rather than letting its results repopulate the + // cleared table. + if (m_symbolsWatcher) + { + m_symbolsWatcher->disconnect(); + m_symbolsWatcher->deleteLater(); + } }); - auto loadSymbolImageButton = new QPushButton(); + m_symbolsPanel->addFilterToggle(":/icons/images/folder.png", "Match Image Names", + [this](bool checked) { m_symbolTable->symbolsModel()->setMatchImageNames(checked); }); + + auto loadSymbolImageButton = m_symbolsPanel->addSelectionButton("Load Image"); connect(loadSymbolImageButton, &QPushButton::clicked, [this](bool) { auto selected = m_symbolTable->selectionModel()->selectedRows(); std::vector addresses; for (const auto& row : selected) - addresses.push_back(row.data().toString().toULongLong(nullptr, 16)); + addresses.push_back(m_symbolTable->getSymbolAtRow(row.row()).address); loadImagesWithAddr(addresses); }); - loadSymbolImageButton->setText("Load Image"); - - // Shows the current selected rows image name. - auto currentImageLabel = new QLabel(this); - currentImageLabel->setText(""); - currentImageLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - connect(m_symbolTable->selectionModel(), &QItemSelectionModel::currentRowChanged, this, [this, currentImageLabel](const QModelIndex ¤t, const QModelIndex &) { - auto symbol = m_symbolTable->getSymbolAtRow(current.row()); + + connect(m_symbolTable, &SymbolTableView::activated, this, [this](const QModelIndex& index){ + auto symbol = m_symbolTable->getSymbolAtRow(index.row()); + auto controller = SharedCacheController::GetController(*this->m_data); if (!controller) return; + auto image = controller->GetImageContaining(symbol.address); - if (image) - currentImageLabel->setText("Image: " + QString::fromStdString(image->name)); - else - currentImageLabel->setText(""); + if (!image.has_value()) + return; + + if (controller->IsImageLoaded(*image)) + { + navigateToAddress(symbol.address); + return; + } + + promptToLoadImage(image->name, image->headerAddress, symbol.address); }); - auto symbolFooterLayout = new QHBoxLayout; - symbolFooterLayout->addWidget(loadSymbolImageButton); - symbolFooterLayout->addWidget(currentImageLabel); - symbolFooterLayout->setAlignment(Qt::AlignLeft); + m_triageTabs->addTab(m_symbolsPanel, "Symbols"); + m_triageTabs->setCanCloseTab(m_symbolsPanel, false); +} - auto symbolLayout = new QVBoxLayout; - symbolLayout->addWidget(symbolFilterEdit); - symbolLayout->addWidget(m_symbolTable); - symbolLayout->addLayout(symbolFooterLayout); - auto symbolWidget = new QWidget; - symbolWidget->setLayout(symbolLayout); +bool DSCTriageView::startSymbolLoad() +{ + // The controller is not available until view init has finished. Retry on the next activation. + auto controller = SharedCacheController::GetController(*m_data); + if (!controller) + return false; - connect(m_symbolTable, &SymbolTableView::activated, this, [=, this](const QModelIndex& index){ - auto symbol = m_symbolTable->getSymbolAtRow(index.row()); - auto dialog = new QMessageBox(this); + m_symbolTable->setNameSources(*controller); + m_symbolsPanel->statusLabel()->setText("Loading…"); + typedef std::vector SymbolList; + QPointer> watcher = new QFutureWatcher(this); + m_symbolsWatcher = watcher; + connect(watcher, &QFutureWatcher::finished, this, [watcher, this]() { + if (!watcher) + return; + m_symbolTable->symbolsModel()->appendSymbols(watcher->result()); + m_symbolsPanel->finishLoad(); + watcher->deleteLater(); + }); + QFuture future = QtConcurrent::run([controller]() { return controller->GetSymbols(); }); + watcher->setFuture(future); + connect(this, &QObject::destroyed, this, [watcher]() { + if (watcher && watcher->isRunning()) { + watcher->cancel(); + watcher->waitForFinished(); + } + }); + return true; +} + + +void DSCTriageView::promptToLoadImage(const std::string& imageName, uint64_t address, uint64_t navigateTo) +{ + auto dialog = new QMessageBox(this); + dialog->setText("Load " + QString::fromStdString(imageName) + "?"); + auto loadButton = dialog->addButton("Load Image", QMessageBox::AcceptRole); + dialog->addButton(QMessageBox::Cancel); + dialog->setDefaultButton(loadButton); + + connect(dialog, &QMessageBox::buttonClicked, this, [=, this](QAbstractButton* button) + { + if (button == loadButton) + loadImagesWithAddr({address}, false, navigateTo); + }); + + dialog->exec(); +} + + +void DSCTriageView::initStringsTab() +{ + m_stringsTable = new StringsTableView(this); + m_stringsPanel = new TriageTablePanel(this, m_stringsTable, "Filter strings", "strings"); + m_stringsPanel->setLoader([this] { return startStringScan(); }); + m_stringsPanel->setClearHandler([this] { + m_stringsPollTimer->stop(); + m_stringScanner.reset(); + }); + // Strings hidden by the visibility toggles are not part of the searched population, so they + // do not appear in the count unless a text filter narrows it. + m_stringsPanel->setBaselineCount( + [this] { return m_stringsTable->stringsModel()->baselineStringCount(); }); + + m_stringsPanel->addFilterToggle(":/icons/images/folder.png", "Match Image Names", + [this](bool checked) { m_stringsTable->stringsModel()->setMatchImageNames(checked); }); + // Strings in regions that belong to no image (dyld data and other non-image regions) are + // rarely of interest, so they are hidden unless this is toggled on. + m_stringsPanel->addFilterToggle(":/icons/images/stack.png", "Show Non-Image Strings", + [this](bool checked) { m_stringsTable->stringsModel()->setShowNonImageStrings(checked); }); + + auto loadStringImageButton = m_stringsPanel->addSelectionButton("Load Image"); + connect(loadStringImageButton, &QPushButton::clicked, [this](bool) { + auto selected = m_stringsTable->selectionModel()->selectedRows(); + std::vector imageAddresses; + for (const auto& row : selected) + { + const auto string = m_stringsTable->getStringAtRow(row.row()); + if (string.imageStart) + imageAddresses.push_back(string.address); + else + loadStringRegion(string, std::nullopt); + } + loadImagesWithAddr(imageAddresses); + }); + + connect(m_stringsTable, &StringsTableView::activated, this, [this](const QModelIndex& index) { + const auto string = m_stringsTable->getStringAtRow(index.row()); auto controller = SharedCacheController::GetController(*this->m_data); if (!controller) return; - auto image = controller->GetImageContaining(symbol.address); - if (!image.has_value()) + // Strings outside any image (e.g. the coalesced selector pool) load just their region. + if (!string.imageStart) + { + loadStringRegion(string, string.address); return; + } - dialog->setText("Load " + QString::fromStdString(image->name) + "?"); - dialog->setStandardButtons(QMessageBox::Yes | QMessageBox::No); + auto image = controller->GetImageAt(string.imageStart); + if (!image.has_value()) + return; - connect(dialog, &QMessageBox::buttonClicked, this, [=, this](QAbstractButton* button) + if (controller->IsImageLoaded(*image)) { - if (button == dialog->button(QMessageBox::Yes)) - loadImagesWithAddr({image->headerAddress}); - }); + navigateToAddress(string.address); + return; + } - dialog->exec(); + promptToLoadImage(image->name, image->headerAddress, string.address); }); - m_triageTabs->addTab(symbolWidget, "Symbols"); - m_triageTabs->setCanCloseTab(symbolWidget, false); + m_stringsPollTimer = new QTimer(this); + m_stringsPollTimer->setInterval(250); + connect(m_stringsPollTimer, &QTimer::timeout, this, &DSCTriageView::pollStringScan); + + m_triageTabs->addTab(m_stringsPanel, "Strings"); + m_triageTabs->setCanCloseTab(m_stringsPanel, false); +} + + +void DSCTriageView::showEvent(QShowEvent* event) +{ + QWidget::showEvent(event); + m_stringsPanel->setViewVisible(true); + m_symbolsPanel->setViewVisible(true); +} + + +void DSCTriageView::hideEvent(QHideEvent* event) +{ + QWidget::hideEvent(event); + m_stringsPanel->setViewVisible(false); + m_symbolsPanel->setViewVisible(false); +} + + +bool DSCTriageView::startStringScan() +{ + // The controller is not available until view init has finished. Retry on the next activation. + auto controller = SharedCacheController::GetController(*m_data); + if (!controller) + return false; + + m_stringsTable->setNameSources(*controller); + m_stringScanner = controller->CreateStringScanner(); + m_stringScanner->Start(); + m_stringsPanel->statusLabel()->setText("Scanning…"); + m_stringsPollTimer->start(); + return true; +} + + +void DSCTriageView::pollStringScan() +{ + if (!m_stringScanner) + return; + + auto model = m_stringsTable->stringsModel(); + constexpr uint64_t maxBatchSize = 250000; + constexpr qint64 maxIngestMilliseconds = 150; + // Ingest batches until the time budget is spent, then yield so the UI stays responsive. + QElapsedTimer ingestTimer; + ingestTimer.start(); + do + { + auto batch = m_stringScanner->TakeStrings(maxBatchSize); + if (batch.empty()) + break; + model->appendStrings(std::move(batch)); + } while (ingestTimer.elapsed() < maxIngestMilliseconds); + + const bool scanComplete = m_stringScanner->IsComplete(); + const QLocale locale; + if (scanComplete && model->totalRowCount() == m_stringScanner->GetStringCount()) + { + m_stringsPollTimer->stop(); + m_stringScanner.reset(); + m_stringsPanel->finishLoad(); + } + else if (scanComplete) + { + // The scan has finished but the table is still ingesting batched results. + const QString totalStrings = locale.toString(static_cast(m_stringScanner->GetStringCount())); + const QString fetchedStrings = + locale.toString(static_cast(model->totalRowCount())).rightJustified(totalStrings.size()); + m_stringsPanel->statusLabel()->setText(QString("Loading… %1 / %2 strings").arg(fetchedStrings, totalStrings)); + } + else + { + const auto [current, total] = m_stringScanner->GetProgress(); + const QString totalMB = locale.toString(static_cast(total / (1024 * 1024))); + // Pad the growing values so the label width stays stable while scanning. + const QString currentMB = + locale.toString(static_cast(current / (1024 * 1024))).rightJustified(totalMB.size()); + const QString stringCount = + locale.toString(static_cast(model->totalRowCount())).rightJustified(10); + m_stringsPanel->statusLabel()->setText( + QString("Scanning… %1 / %2 MB — %3 strings").arg(currentMB, totalMB, stringCount)); + } +} + + +void DSCTriageView::loadStringRegion(const CacheString& string, std::optional navigateTo) +{ + auto controller = SharedCacheController::GetController(*m_data); + if (!controller) + return; + + auto region = controller->GetRegionAt(string.regionStart); + if (!region.has_value()) + return; + + QPointer> watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [watcher, navigateTo, this]() { + if (!watcher) + return; + if (watcher->result()) + m_data->UpdateAnalysis(); + if (navigateTo) + navigateToAddress(*navigateTo); + watcher->deleteLater(); + }); + QFuture future = QtConcurrent::run([this, controller, region]() { + return controller->ApplyRegion(*this->m_data, *region); + }); + watcher->setFuture(future); + connect(this, &QObject::destroyed, this, [watcher]() { + if (watcher && watcher->isRunning()) { + watcher->cancel(); + watcher->waitForFinished(); + } + }); +} + + +void DSCTriageView::navigateToAddress(uint64_t address) +{ + ViewFrame* frame = ViewFrame::viewFrameForWidget(this); + if (!frame) + return; + // Navigating the frame's current view would hit DSCTriageView::navigate, which is not + // navigable. Switch to the linear view of the cache instead. + frame->navigate("Linear:" + frame->getCurrentDataType(), address, true, true); } @@ -478,6 +718,9 @@ void DSCTriageView::initCacheInfoTables() connect(mappingFilterEdit, &FilterEdit::textChanged, [this, mappingFilterEdit](const QString& filter) { m_mappingTable->setFilter(filter.toStdString(), mappingFilterEdit->getFilterOptions()); }); + connect(mappingFilterEdit, &FilterEdit::optionsChanged, [this, mappingFilterEdit](FilterOptions options) { + m_mappingTable->setFilter(mappingFilterEdit->text().toStdString(), options); + }); auto mappingHeaderLayout = new QHBoxLayout; mappingHeaderLayout->addWidget(mappingLabel); @@ -491,6 +734,9 @@ void DSCTriageView::initCacheInfoTables() connect(regionFilterEdit, &FilterEdit::textChanged, [this, regionFilterEdit](const QString& filter) { m_regionTable->setFilter(filter.toStdString(), regionFilterEdit->getFilterOptions()); }); + connect(regionFilterEdit, &FilterEdit::optionsChanged, [this, regionFilterEdit](FilterOptions options) { + m_regionTable->setFilter(regionFilterEdit->text().toStdString(), options); + }); auto regionHeaderLayout = new QHBoxLayout; regionHeaderLayout->addWidget(regionLabel); @@ -614,6 +860,8 @@ void DSCTriageView::RefreshData() // TODO: This should use `QSortFilterProxyModel`, but that's a bigger change. m_mappingTable->setSortingEnabled(true); - - m_symbolTable->populateSymbols(*m_data); + // Symbols and strings are loaded lazily when their tabs are shown. + // Discard any loaded content so the reload picks up the refreshed cache information. + m_symbolsPanel->resetContent(); + m_stringsPanel->resetContent(); } diff --git a/view/sharedcache/ui/dsctriage.h b/view/sharedcache/ui/dsctriage.h index 03eea6be1c..8f9bd08006 100644 --- a/view/sharedcache/ui/dsctriage.h +++ b/view/sharedcache/ui/dsctriage.h @@ -1,6 +1,8 @@ +#include #include #include #include +#include #include #include #include @@ -8,7 +10,9 @@ #include #include #include +#include #include "filter.h" +#include "stringstable.h" #include "symboltable.h" #include "ui/fontsettings.h" #include "uicontext.h" @@ -191,6 +195,13 @@ class DSCTriageView : public QWidget, public View, public UIContextNotification QStandardItemModel* m_imageModel; SymbolTableView* m_symbolTable; + TriageTablePanel* m_symbolsPanel; + QPointer>> m_symbolsWatcher; + + StringsTableView* m_stringsTable; + TriageTablePanel* m_stringsPanel; + QTimer* m_stringsPollTimer; + std::unique_ptr m_stringScanner; FilterableTableView* m_regionTable; @@ -212,11 +223,23 @@ class DSCTriageView : public QWidget, public View, public UIContextNotification void OnAfterOpenFile(UIContext* context, FileContext* file, ViewFrame* frame) override; void RefreshData(); +protected: + void showEvent(QShowEvent* event) override; + void hideEvent(QHideEvent* event) override; + private: - void loadImagesWithAddr(const std::vector& addresses, bool includeDependencies = false); + void loadImagesWithAddr(const std::vector& addresses, bool includeDependencies = false, + std::optional navigateTo = std::nullopt); void setImageLoaded(uint64_t imageHeaderAddr); + void navigateToAddress(uint64_t address); QWidget* initImageTable(); void initSymbolTable(); + bool startSymbolLoad(); + void initStringsTab(); + bool startStringScan(); + void pollStringScan(); + void promptToLoadImage(const std::string& imageName, uint64_t address, uint64_t navigateTo); + void loadStringRegion(const SharedCacheAPI::CacheString& string, std::optional navigateTo); void initCacheInfoTables(); }; diff --git a/view/sharedcache/ui/stringstable.cpp b/view/sharedcache/ui/stringstable.cpp new file mode 100644 index 0000000000..693e28eaa4 --- /dev/null +++ b/view/sharedcache/ui/stringstable.cpp @@ -0,0 +1,276 @@ +#include "stringstable.h" + +#include "addresstext.h" +#include "theme.h" + +#include +#include + +using namespace SharedCacheAPI; + +namespace { + +enum StringsTableColumn +{ + StringsTableAddressColumn, + StringsTableTypeColumn, + StringsTableLengthColumn, + StringsTableImageColumn, + StringsTableStringColumn, + StringsTableColumnCount, +}; + +QString StringTypeAsString(BNStringType type) +{ + switch (type) + { + case AsciiString: + return QStringLiteral("ASCII"); + case Utf8String: + return QStringLiteral("UTF-8"); + case Utf16String: + return QStringLiteral("UTF-16"); + case Utf32String: + return QStringLiteral("UTF-32"); + default: + return QStringLiteral("Unknown"); + } +} + +QString DisplayTextForString(const CacheString& string) +{ + QString text = QString::fromUtf8(string.text.c_str(), string.text.size()); + text.replace('\n', QStringLiteral("\\n")); + text.replace('\r', QStringLiteral("\\r")); + text.replace('\t', QStringLiteral("\\t")); + return text; +} + + +// The image lookup key for a string, or nullopt when the string is outside any image. +std::optional ImageKey(const CacheString& string) +{ + if (string.imageStart == 0) + return std::nullopt; + return string.imageStart; +} + +QString ImageNameForString(const ImageNameLookup::State& names, const CacheString& string) +{ + return ImageNameLookup::displayName(names, ImageKey(string), string.regionStart); +} + +} // namespace + + +StringsTableModel::StringsTableModel(QWidget* parent) : TriageTableRowsModel(parent) +{ + m_finalComparator = [](const CacheString& a, const CacheString& b) { return a.address < b.address; }; + // Establish the initial row visibility. Non-image strings are hidden by default. + applyFilter(); +} + + +int StringsTableModel::columnCount(const QModelIndex& parent) const +{ + Q_UNUSED(parent); + return StringsTableColumnCount; +} + + +QVariant StringsTableModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + const size_t row = static_cast(index.row()); + BN_ASSERT(row < m_rows.displayCount()); + if (row >= m_rows.displayCount()) + return QVariant(); + + switch (role) + { + case Qt::DisplayRole: + { + const auto& string = stringAt(index.row()); + + switch (index.column()) + { + case StringsTableAddressColumn: + return QString::fromStdString(AddressText(string.address, m_addressWidth)); + case StringsTableTypeColumn: + return StringTypeAsString(string.type); + case StringsTableLengthColumn: + return QString::number(string.rawLength); + case StringsTableImageColumn: + return ImageNameForString(*m_names.snapshot(), string); + case StringsTableStringColumn: + return DisplayTextForString(string); + default: + return QVariant(); + } + } + case Qt::ForegroundRole: + switch (index.column()) + { + case StringsTableAddressColumn: + return getThemeColor(AddressColor); + case StringsTableTypeColumn: + return getThemeColor(TypeNameColor); + case StringsTableLengthColumn: + return getThemeColor(NumberColor); + case StringsTableStringColumn: + return getThemeColor(StringColor); + default: + return QVariant(); + } + case Qt::ToolTipRole: + { + if (index.column() != StringsTableImageColumn) + return QVariant(); + const auto& string = stringAt(index.row()); + if (QString tooltip = m_names.tooltip(ImageKey(string), string.regionStart); !tooltip.isEmpty()) + return tooltip; + return QVariant(); + } + case Qt::FontRole: + return m_font; + default: + return QVariant(); + } +} + + +QVariant StringsTableModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (role != Qt::DisplayRole || orientation != Qt::Horizontal) + return QVariant(); + + switch (section) + { + case StringsTableAddressColumn: + return QString("Address"); + case StringsTableTypeColumn: + return QString("Type"); + case StringsTableLengthColumn: + return QString("Length"); + case StringsTableImageColumn: + return QString("Image"); + case StringsTableStringColumn: + return QString("String"); + default: + return QVariant(); + } +} + + +StringsTableModel::KeyOrdering StringsTableModel::orderingForColumn(int column) const +{ + switch (column) + { + case StringsTableAddressColumn: + return [](const CacheString& a, const CacheString& b) { return a.address <=> b.address; }; + case StringsTableTypeColumn: + return [](const CacheString& a, const CacheString& b) { return a.type <=> b.type; }; + case StringsTableLengthColumn: + return [](const CacheString& a, const CacheString& b) { return a.rawLength <=> b.rawLength; }; + case StringsTableStringColumn: + return [](const CacheString& a, const CacheString& b) { return a.text <=> b.text; }; + case StringsTableImageColumn: + return ImageColumnOrdering(*m_names.snapshot()); + default: + return nullptr; + } +} + + +void StringsTableModel::setNameSources(const SharedCacheController& controller) +{ + m_names.build(controller); + m_addressWidth = BNGetAddressRenderedWidth(m_names.maxAddress()); +} + + +void StringsTableModel::appendStrings(std::vector strings) +{ + m_imageStringCount += std::ranges::count_if( + strings, [](const CacheString& string) { return string.imageStart != 0; }); + appendRows(std::move(strings)); +} + + +void StringsTableModel::clearRows() +{ + m_imageStringCount = 0; + TriageTableRowsModel::clearRows(); +} + + +void StringsTableModel::setShowNonImageStrings(bool show) +{ + if (m_showNonImageStrings == show) + return; + m_showNonImageStrings = show; + applyFilter(); +} + + +bool StringsTableModel::rowsEquivalent(const CacheString& a, const CacheString& b) const +{ + return a.address == b.address; +} + + +void StringsTableModel::applyFilter() +{ + if (m_filterText.empty() && m_showNonImageStrings) + { + m_rows.setFilter(nullptr); + return; + } + + const auto snapshot = filterSnapshot(); + const uint32_t addressWidth = m_addressWidth; + const auto names = m_names.snapshot(); + + m_rows.setFilterFactory( + [snapshot, addressWidth, names, showNonImageStrings = m_showNonImageStrings]() -> Predicate { + FilterParams params = MakeFilterParams(snapshot); + return [params = std::move(params), addressWidth, names, showNonImageStrings](const CacheString& string) { + if (!showNonImageStrings && !string.imageStart) + return false; + if (params.text.empty()) + return true; + QString imageName; + if (params.matchImageNames) + imageName = ImageNameForString(*names, string); + return MatchesText(params, string.text, string.address, addressWidth, imageName); + }; + }); +} + + +StringsTableView::StringsTableView(QWidget* parent) : TriageTableView(parent) +{ + m_model = new StringsTableModel(this); + setTriageModel(m_model, StringsTableAddressColumn); + applyDefaultColumnWidths(); +} + + +void StringsTableView::setNameSources(const SharedCacheController& controller) +{ + m_model->setNameSources(controller); + applyDefaultColumnWidths(); +} + + +void StringsTableView::applyDefaultColumnWidths() +{ + fitColumn(StringsTableAddressColumn, {QString(m_model->addressWidth(), QChar('0'))}); + fitColumn(StringsTableTypeColumn, + {StringTypeAsString(AsciiString), StringTypeAsString(Utf8String), StringTypeAsString(Utf16String), + StringTypeAsString(Utf32String)}); + fitColumn(StringsTableLengthColumn, {}); + fitColumn(StringsTableImageColumn, {m_model->names().widestImageColumnText()}); +} diff --git a/view/sharedcache/ui/stringstable.h b/view/sharedcache/ui/stringstable.h new file mode 100644 index 0000000000..0a4825c7e8 --- /dev/null +++ b/view/sharedcache/ui/stringstable.h @@ -0,0 +1,70 @@ +#pragma once + +#include "triagetable.h" + + +class StringsTableModel : public TriageTableRowsModel +{ + ImageNameLookup m_names; + bool m_showNonImageStrings = false; + size_t m_imageStringCount = 0; + + // The ascending three-way ordering for a column, or null if the column is not sortable. + KeyOrdering orderingForColumn(int column) const override; + +protected: + void applyFilter() override; + bool rowsEquivalent( + const SharedCacheAPI::CacheString& a, const SharedCacheAPI::CacheString& b) const override; + +public: + explicit StringsTableModel(QWidget* parent); + + int columnCount(const QModelIndex& parent) const override; + QVariant data(const QModelIndex& index, int role) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + + // Build the Image column lookup tables. + void setNameSources(const SharedCacheAPI::SharedCacheController& controller); + + const ImageNameLookup& names() const { return m_names; } + + // Append a batch of scan results, filtering only the new rows. + void appendStrings(std::vector strings); + + void clearRows() override; + + // Whether strings in regions not belonging to any image are shown. + void setShowNonImageStrings(bool show); + + // Count of strings eligible for display under the current visibility options, ignoring any + // text filter. + size_t baselineStringCount() const + { + return m_showNonImageStrings ? totalRowCount() : m_imageStringCount; + } + + const SharedCacheAPI::CacheString& stringAt(int row) const { return rowAt(row); } +}; + + +class StringsTableView : public TriageTableView +{ + StringsTableModel* m_model; + +public: + explicit StringsTableView(QWidget* parent); + + StringsTableModel* stringsModel() const { return m_model; } + + // Build the Image column lookup tables and refit the default column widths. + void setNameSources(const SharedCacheAPI::SharedCacheController& controller); + + SharedCacheAPI::CacheString getStringAtRow(int row) const + { + return m_model->stringAt(row); + } + +protected: + void applyDefaultColumnWidths() override; +}; diff --git a/view/sharedcache/ui/symboltable.cpp b/view/sharedcache/ui/symboltable.cpp index 2a6cb467cb..b012b637de 100644 --- a/view/sharedcache/ui/symboltable.cpp +++ b/view/sharedcache/ui/symboltable.cpp @@ -1,61 +1,136 @@ -#include #include "symboltable.h" -#include +#include "addresstext.h" +#include "theme.h" -#include "ui/fontsettings.h" +#include +#include -#include "binaryninjaapi.h" - -using namespace BinaryNinja; using namespace SharedCacheAPI; +namespace { -SymbolTableModel::SymbolTableModel(SymbolTableView* parent) : - QAbstractTableModel(parent), m_parent(parent), m_displaySymbols(&m_symbols) +enum SymbolsTableColumn +{ + SymbolsTableAddressColumn, + SymbolsTableTypeColumn, + SymbolsTableImageColumn, + SymbolsTableNameColumn, + SymbolsTableColumnCount, +}; + +QString SymbolTypeAsString(BNSymbolType type) { - // TODO: Need to implement updating this font if it is changed by the user - m_font = getMonospaceFont(parent); + const std::string name = GetSymbolTypeAsString(type); + return QString::fromUtf8(name.c_str(), name.size()); } -int SymbolTableModel::rowCount(const QModelIndex& parent) const { - Q_UNUSED(parent); - return static_cast(m_displaySymbols->size()); +const SymbolTableModel::AddressRange* RangeContaining( + const std::vector& ranges, uint64_t address) +{ + auto it = std::upper_bound(ranges.begin(), ranges.end(), address, + [](uint64_t address, const SymbolTableModel::AddressRange& range) { + return address < range.start; + }); + if (it == ranges.begin()) + return nullptr; + --it; + if (address >= it->end) + return nullptr; + return &*it; +} + + +// The image lookup key for a row, or nullopt when the symbol is outside any image. +std::optional ImageKey(const SymbolRow& row) +{ + if (row.imageStart == 0) + return std::nullopt; + return row.imageStart; +} + +QString ImageNameForSymbol(const ImageNameLookup::State& names, const SymbolRow& row) +{ + return ImageNameLookup::displayName(names, ImageKey(row), row.regionStart); +} + +} // namespace + + +SymbolTableModel::SymbolTableModel(QWidget* parent) : TriageTableRowsModel(parent) +{ + m_finalComparator = [](const SymbolRow& a, const SymbolRow& b) { return a.symbol.address < b.symbol.address; }; } -int SymbolTableModel::columnCount(const QModelIndex& parent) const { +int SymbolTableModel::columnCount(const QModelIndex& parent) const +{ Q_UNUSED(parent); - // We have 3 columns: Address, Type, Name - return 3; + return SymbolsTableColumnCount; } -QVariant SymbolTableModel::data(const QModelIndex& index, int role) const { - if (!index.isValid() || (role != Qt::DisplayRole && role != Qt::FontRole)) { +QVariant SymbolTableModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + const size_t row = static_cast(index.row()); + BN_ASSERT(row < m_rows.displayCount()); + if (row >= m_rows.displayCount()) return QVariant(); - } switch (role) { case Qt::DisplayRole: { - auto symbol = symbolAt(index.row()); - auto symbolType = GetSymbolTypeAsString(symbol.type); + const auto& symbolRow = rowAt(index.row()); + const auto& symbol = symbolRow.symbol; switch (index.column()) { - case 0: // Address column - return QString("0x%1").arg(symbol.address, 0, 16); // Display address as hexadecimal - case 1: // Type column - return QString::fromUtf8(symbolType.c_str(), symbolType.size()); - case 2: // Name column + case SymbolsTableAddressColumn: + return QString::fromStdString(AddressText(symbol.address, m_addressWidth)); + case SymbolsTableTypeColumn: + return SymbolTypeAsString(symbol.type); + case SymbolsTableImageColumn: + return ImageNameForSymbol(*m_names.snapshot(), symbolRow); + case SymbolsTableNameColumn: return QString::fromUtf8(symbol.name.c_str(), symbol.name.size()); default: return QVariant(); } } + case Qt::ForegroundRole: + switch (index.column()) + { + case SymbolsTableAddressColumn: + return getThemeColor(AddressColor); + case SymbolsTableTypeColumn: + return getThemeColor(TypeNameColor); + case SymbolsTableNameColumn: + switch (symbolAt(index.row()).type) + { + case FunctionSymbol: + return getThemeColor(CodeSymbolColor); + case DataSymbol: + return getThemeColor(DataSymbolColor); + default: + return QVariant(); + } + default: + return QVariant(); + } + case Qt::ToolTipRole: + { + if (index.column() != SymbolsTableImageColumn) + return QVariant(); + const auto& symbolRow = rowAt(index.row()); + if (QString tooltip = m_names.tooltip(ImageKey(symbolRow), symbolRow.regionStart); !tooltip.isEmpty()) + return tooltip; + return QVariant(); + } case Qt::FontRole: return m_font; default: @@ -64,17 +139,20 @@ QVariant SymbolTableModel::data(const QModelIndex& index, int role) const { } -QVariant SymbolTableModel::headerData(int section, Qt::Orientation orientation, int role) const { - if (role != Qt::DisplayRole || orientation != Qt::Horizontal) { +QVariant SymbolTableModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (role != Qt::DisplayRole || orientation != Qt::Horizontal) return QVariant(); - } - switch (section) { - case 0: + switch (section) + { + case SymbolsTableAddressColumn: return QString("Address"); - case 1: + case SymbolsTableTypeColumn: return QString("Type"); - case 2: + case SymbolsTableImageColumn: + return QString("Image"); + case SymbolsTableNameColumn: return QString("Name"); default: return QVariant(); @@ -82,170 +160,101 @@ QVariant SymbolTableModel::headerData(int section, Qt::Orientation orientation, } -void SymbolTableModel::sort(int column, Qt::SortOrder order) +SymbolTableModel::KeyOrdering SymbolTableModel::orderingForColumn(int column) const { - beginResetModel(); - - std::function comparator; - switch (column) { - case 0: // Address column - comparator = [](const CacheSymbol& a, const CacheSymbol& b) { - return a.address < b.address; - }; - break; - case 1: // Type column - comparator = [](const CacheSymbol& a, const CacheSymbol& b) { - return GetSymbolTypeAsString(a.type) < GetSymbolTypeAsString(b.type); - }; - break; - case 2: // Name column - comparator = [](const CacheSymbol& a, const CacheSymbol& b) { - return a.name < b.name; - }; - break; + case SymbolsTableAddressColumn: + return [](const SymbolRow& a, const SymbolRow& b) { return a.symbol.address <=> b.symbol.address; }; + case SymbolsTableTypeColumn: + return [](const SymbolRow& a, const SymbolRow& b) { return a.symbol.type <=> b.symbol.type; }; + case SymbolsTableNameColumn: + return [](const SymbolRow& a, const SymbolRow& b) { return a.symbol.name <=> b.symbol.name; }; + case SymbolsTableImageColumn: + return ImageColumnOrdering(*m_names.snapshot()); default: - endResetModel(); - return; + return nullptr; } +} - if (order == Qt::DescendingOrder) - { - std::sort(m_displaySymbols->begin(), m_displaySymbols->end(), - [&comparator](const CacheSymbol& a, const CacheSymbol& b) { return comparator(b, a); }); - } - else - { - std::sort(m_displaySymbols->begin(), m_displaySymbols->end(), comparator); - } - endResetModel(); +void SymbolTableModel::setNameSources(const SharedCacheController& controller) +{ + m_names.build(controller); + m_addressWidth = BNGetAddressRenderedWidth(m_names.maxAddress()); + + auto ranges = std::make_shared>(); + for (const auto& region : controller.GetRegions()) + ranges->push_back({region.start, region.start + region.size, region.imageStart}); + std::sort(ranges->begin(), ranges->end(), + [](const AddressRange& a, const AddressRange& b) { return a.start < b.start; }); + m_ranges = std::move(ranges); } -void SymbolTableModel::updateSymbols(std::vector symbols) +void SymbolTableModel::appendSymbols(std::vector symbols) { - m_symbols = std::move(symbols); - setFilter(m_filter, m_filterOptions); + std::vector rows; + rows.reserve(symbols.size()); + for (auto& symbol : symbols) + { + const auto* range = RangeContaining(*m_ranges, symbol.address); + rows.push_back({std::move(symbol), range ? range->imageStart.value_or(0) : 0, + range ? range->start : 0}); + } + appendRows(std::move(rows)); } -const CacheSymbol& SymbolTableModel::symbolAt(int row) const +bool SymbolTableModel::rowsEquivalent(const SymbolRow& a, const SymbolRow& b) const { - return m_displaySymbols->at(row); + return a.symbol.address == b.symbol.address && a.symbol.name == b.symbol.name; } -void SymbolTableModel::setFilter(const std::string& text, FilterOptions options) +void SymbolTableModel::applyFilter() { - m_filter = text; - m_filterOptions = options; - bool caseSensitive = options.testFlag(CaseSensitiveOption); - beginResetModel(); - // Skip filtering if no filter applied. - if (m_filter.empty()) + if (m_filterText.empty()) { - m_filteredSymbols = {}; - m_displaySymbols = &m_symbols; - } - else - { - // Clear the filtered symbols while preserving the capacity - m_filteredSymbols.clear(); - - for (const auto& symbol : m_symbols) - { - const std::string& symbolName = symbol.name; - bool match; - if (caseSensitive) - { - match = (symbolName.find(m_filter) != std::string::npos); - } - else - { - auto it = std::search( - symbolName.begin(), symbolName.end(), - m_filter.begin(), m_filter.end(), - [](char c1, char c2) { return std::tolower(c1) == std::tolower(c2); } - ); - - match = (it != symbolName.end()); - } - - if (match) - { - m_filteredSymbols.push_back(symbol); - } - } - - // If the filtered vector is using less than 25% of its capacity, - // shrink it to reduce memory usage. - if (m_filteredSymbols.size() < m_filteredSymbols.capacity() / 4) - m_filteredSymbols.shrink_to_fit(); - - m_displaySymbols = &m_filteredSymbols; + m_rows.setFilter(nullptr); + return; } - endResetModel(); + const auto snapshot = filterSnapshot(); + const uint32_t addressWidth = m_addressWidth; + const auto names = m_names.snapshot(); + + m_rows.setFilterFactory([snapshot, addressWidth, names]() -> Predicate { + FilterParams params = MakeFilterParams(snapshot); + return [params = std::move(params), addressWidth, names](const SymbolRow& row) { + QString imageName; + if (params.matchImageNames) + imageName = ImageNameForSymbol(*names, row); + return MatchesText(params, row.symbol.name, row.symbol.address, addressWidth, imageName); + }; + }); } - - -SymbolTableView::SymbolTableView(QWidget* parent) : - QTableView(parent), m_model(new SymbolTableModel(this)) +SymbolTableView::SymbolTableView(QWidget* parent) : TriageTableView(parent) { - // Set up the filter model - setModel(m_model); - - // Configure view settings - horizontalHeader()->setSectionResizeMode(0, QHeaderView::Fixed); - horizontalHeader()->setSectionResizeMode(1, QHeaderView::Fixed); - horizontalHeader()->setSectionResizeMode(2, QHeaderView::Stretch); - setEditTriggers(QAbstractItemView::NoEditTriggers); - setSelectionBehavior(QAbstractItemView::SelectRows); - setSelectionMode(QAbstractItemView::SingleSelection); - verticalHeader()->setVisible(false); - - sortByColumn(0, Qt::AscendingOrder); - setSortingEnabled(true); + m_model = new SymbolTableModel(this); + setTriageModel(m_model, SymbolsTableAddressColumn); + applyDefaultColumnWidths(); } -SymbolTableView::~SymbolTableView() = default; -void SymbolTableView::populateSymbols(BinaryView &view) +void SymbolTableView::setNameSources(const SharedCacheController& controller) { - if (auto controller = SharedCacheController::GetController(view)) { - typedef std::vector SymbolList; - // Retrieve the symbols from the controller in a future than pass that to the model. - QPointer> watcher = new QFutureWatcher(this); - connect(watcher, &QFutureWatcher::finished, this, [watcher, this]() { - if (watcher) - { - auto symbols = watcher->result(); - m_model->updateSymbols(std::move(symbols)); - - // Reapply the current sort after repopulating the model - // TODO: The model should use `QSortFilterProxyModel`, but that's a bigger change. - setSortingEnabled(true); - } - }); - QFuture future = QtConcurrent::run([controller]() { - return controller->GetSymbols(); - }); - watcher->setFuture(future); - connect(this, &QObject::destroyed, this, [watcher]() { - if (watcher && watcher->isRunning()) { - watcher->cancel(); - watcher->waitForFinished(); - } - }); - } + m_model->setNameSources(controller); + applyDefaultColumnWidths(); } -void SymbolTableView::setFilter(const std::string& filter, FilterOptions options) +void SymbolTableView::applyDefaultColumnWidths() { - m_model->setFilter(filter, options); + fitColumn(SymbolsTableAddressColumn, {QString(m_model->addressWidth(), QChar('0'))}); + fitColumn(SymbolsTableTypeColumn, + {SymbolTypeAsString(FunctionSymbol), SymbolTypeAsString(DataSymbol), QStringLiteral("Unknown")}); + fitColumn(SymbolsTableImageColumn, {m_model->names().widestImageColumnText()}); } diff --git a/view/sharedcache/ui/symboltable.h b/view/sharedcache/ui/symboltable.h index bcaef2946f..481020fb03 100644 --- a/view/sharedcache/ui/symboltable.h +++ b/view/sharedcache/ui/symboltable.h @@ -1,106 +1,84 @@ #pragma once -#include -#include "viewframe.h" +#include "triagetable.h" -#include -#include -#include "filter.h" +#include +#include +#include -#ifndef BINARYNINJA_DSCSYMBOLTABLE_H -#define BINARYNINJA_DSCSYMBOLTABLE_H -class SymbolTableView; +// A cache symbol together with the image and region it belongs to, resolved once at load so the +// Image column never has to binary search the regions per render, filter, or comparison. +struct SymbolRow +{ + SharedCacheAPI::CacheSymbol symbol; + // Header address of the owning image, or 0 if the symbol is outside any image. + uint64_t imageStart; + // Start address of the owning region, or 0 if the symbol is outside any region. + uint64_t regionStart; +}; -class SymbolTableModel : public QAbstractTableModel +class SymbolTableModel : public TriageTableRowsModel { -Q_OBJECT - SymbolTableView* m_parent; - QFont m_font; - std::string m_filter; - FilterOptions m_filterOptions; +public: + // A region's address range, for resolving a symbol's address to the image or region + // containing it. + struct AddressRange + { + uint64_t start; + uint64_t end; + std::optional imageStart; + }; + +private: + // Region address ranges sorted by start address, for resolving symbol addresses. + std::shared_ptr> m_ranges = std::make_shared>(); + ImageNameLookup m_names; - std::vector m_symbols; - std::vector m_filteredSymbols; + // The ascending three-way ordering for a column, or null if the column is not sortable. + KeyOrdering orderingForColumn(int column) const override; - // A pointer to either m_symbols or m_filteredSymbols, depending on whether a filter is applied. - std::vector *m_displaySymbols = nullptr; +protected: + void applyFilter() override; + bool rowsEquivalent(const SymbolRow& a, const SymbolRow& b) const override; public: - explicit SymbolTableModel(SymbolTableView* parent); + explicit SymbolTableModel(QWidget* parent); - int rowCount(const QModelIndex& parent) const override; int columnCount(const QModelIndex& parent) const override; QVariant data(const QModelIndex& index, int role) const override; QVariant headerData(int section, Qt::Orientation orientation, int role) const override; - void sort(int column, Qt::SortOrder order) override; - void updateSymbols(std::vector symbols); - void setFilter(const std::string& text, FilterOptions options); + // Build the Image column lookup tables and the address resolution ranges. + void setNameSources(const SharedCacheAPI::SharedCacheController& controller); + + // Resolve each symbol's image and region once, then append them. + void appendSymbols(std::vector symbols); - const SharedCacheAPI::CacheSymbol& symbolAt(int row) const; + const ImageNameLookup& names() const { return m_names; } + + const SharedCacheAPI::CacheSymbol& symbolAt(int row) const { return rowAt(row).symbol; } }; -class SymbolTableView : public QTableView, public FilterTarget +class SymbolTableView : public TriageTableView { -Q_OBJECT - friend class SymbolTableModel; - SymbolTableModel* m_model; public: explicit SymbolTableView(QWidget* parent); - ~SymbolTableView() override; - - // Call this to populate the symbols from the given view. - void populateSymbols(BinaryNinja::BinaryView& view); - - void scrollToFirstItem() override - { - if (model()->rowCount() > 0) { - QModelIndex top = indexAt(rect().topLeft()); - if (top.isValid()) - scrollTo(top); - } - } - - void scrollToCurrentItem() override - { - QModelIndex currentIndex = selectionModel()->currentIndex(); - if (currentIndex.isValid()) - scrollTo(currentIndex); - } - void ensureSelection() override - { - QModelIndex current = selectionModel()->currentIndex(); - if (current.isValid() || model()->rowCount() == 0) - return; - - if (auto top = indexAt(rect().topLeft()); top.isValid()) - { - selectionModel()->select(top, QItemSelectionModel::ClearAndSelect); - setCurrentIndex(top); - } - } + SymbolTableModel* symbolsModel() const { return m_model; } - - void activateSelection() override - { - ensureSelection(); - if (auto current = selectionModel()->currentIndex(); current.isValid()) - emit activated(current); - } + // Build the Image column lookup tables and refit the default column widths. + void setNameSources(const SharedCacheAPI::SharedCacheController& controller); SharedCacheAPI::CacheSymbol getSymbolAtRow(int row) const { return m_model->symbolAt(row); } - void setFilter(const std::string& filter, FilterOptions options) override; +protected: + void applyDefaultColumnWidths() override; }; - - -#endif // BINARYNINJA_DSCSYMBOLTABLE_H diff --git a/view/sharedcache/ui/triagetable.cpp b/view/sharedcache/ui/triagetable.cpp new file mode 100644 index 0000000000..cf9bd6095a --- /dev/null +++ b/view/sharedcache/ui/triagetable.cpp @@ -0,0 +1,545 @@ +#include "triagetable.h" + +#include "addresstext.h" +#include "theme.h" +#include "ui/fontsettings.h" + +#include +#include +#include + + +using namespace SharedCacheAPI; + + +TriageTableModel::TriageTableModel(QWidget* parent) : QAbstractTableModel(parent) +{ + // TODO: Need to implement updating this font if it is changed by the user + m_font = getMonospaceFont(parent); +} + + +TriageTableModel::FilterParams TriageTableModel::MakeFilterParams(const FilterSnapshot& snapshot) +{ + return {snapshot.text, snapshot.options, + QRegularExpression(QString::fromStdString(snapshot.text), + snapshot.options.testFlag(CaseSensitiveOption) ? QRegularExpression::NoPatternOption + : QRegularExpression::CaseInsensitiveOption), + snapshot.matchImageNames}; +} + + +bool TriageTableModel::MatchesText(const FilterParams& params, const std::string& text, + uint64_t address, uint32_t addressWidth, const QString& imageName) +{ + if (params.options.testFlag(UseRegexOption)) + { + if (params.regex.match(QString::fromUtf8(text.c_str(), text.size())).hasMatch()) + return true; + if (params.regex.match(QString::fromStdString(AddressText(address, addressWidth))).hasMatch()) + return true; + return params.matchImageNames && params.regex.match(imageName).hasMatch(); + } + + const bool caseSensitive = params.options.testFlag(CaseSensitiveOption); + const auto contains = [&](const std::string& haystack) { + if (caseSensitive) + return haystack.find(params.text) != std::string::npos; + const auto lower = [](char c) { + return std::tolower(static_cast(c)); + }; + auto it = std::search(haystack.begin(), haystack.end(), params.text.begin(), params.text.end(), + [lower](char c1, char c2) { return lower(c1) == lower(c2); }); + return it != haystack.end(); + }; + + if (contains(text)) + return true; + if (contains(AddressText(address, addressWidth))) + return true; + return params.matchImageNames && contains(imageName.toStdString()); +} + + +void ImageNameLookup::build(const SharedCacheController& controller) +{ + auto state = std::make_shared(); + for (const auto& image : controller.GetImages()) + { + const auto lastSlash = image.name.find_last_of('/'); + const size_t baseOffset = lastSlash == std::string::npos ? 0 : lastSlash + 1; + state->imageNames[image.headerAddress] = + QString::fromUtf8(image.name.data() + baseOffset, image.name.size() - baseOffset); + state->imagePaths[image.headerAddress] = QString::fromStdString(image.name); + } + for (const auto& region : controller.GetRegions()) + { + state->maxAddress = std::max(state->maxAddress, region.start + region.size); + if (!region.imageStart.has_value()) + state->nonImageRegionNames[region.start] = QString::fromStdString(region.name); + } + m_state = std::move(state); +} + + +QString ImageNameLookup::displayName(const State& state, std::optional imageStart, + uint64_t regionStart) +{ + if (imageStart) + { + if (auto it = state.imageNames.find(*imageStart); it != state.imageNames.end()) + return it->second; + } + if (auto it = state.nonImageRegionNames.find(regionStart); it != state.nonImageRegionNames.end()) + return it->second; + return {}; +} + + +QString ImageNameLookup::tooltip(const State& state, std::optional imageStart, + uint64_t regionStart) +{ + if (imageStart) + { + if (auto it = state.imagePaths.find(*imageStart); it != state.imagePaths.end()) + return it->second; + } + if (auto it = state.nonImageRegionNames.find(regionStart); it != state.nonImageRegionNames.end()) + return it->second; + return {}; +} + + +QString ImageNameLookup::displayName(std::optional imageStart, uint64_t regionStart) const +{ + return displayName(*m_state, imageStart, regionStart); +} + + +QString ImageNameLookup::tooltip(std::optional imageStart, uint64_t regionStart) const +{ + return tooltip(*m_state, imageStart, regionStart); +} + + +QString ImageNameLookup::widestImageColumnText() const +{ + QString widest; + const auto updateWidest = [&widest](const std::map& names) { + for (const auto& [_, name] : names) + if (name.size() > widest.size()) + widest = name; + }; + updateWidest(m_state->imageNames); + updateWidest(m_state->nonImageRegionNames); + return widest; +} + + +void TriageTableModel::setFilter(const std::string& text, FilterOptions options) +{ + m_filterText = text; + m_filterOptions = options; + applyFilter(); +} + + +void TriageTableModel::setMatchImageNames(bool match) +{ + if (m_matchImageNames == match) + return; + m_matchImageNames = match; + if (!m_filterText.empty()) + applyFilter(); +} + + +void TriageTableModel::promoteSortColumn(int column, Qt::SortOrder order) +{ + std::erase_if(m_sortKeys, [column](const auto& key) { return key.first == column; }); + m_sortKeys.insert(m_sortKeys.begin(), {column, order}); +} + + +TriageTableView::TriageTableView(QWidget* parent) : QTableView(parent) +{ + setFont(getMonospaceFont(this)); + setWordWrap(false); + verticalHeader()->setVisible(false); + + // Match the dense row sizing of the regular strings and symbols views. + const int charHeight = (int)(QFontMetricsF(getMonospaceFont(this)).height() + getExtraFontSpacing()); + verticalHeader()->setDefaultSectionSize(charHeight); + verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); + horizontalHeader()->setStretchLastSection(true); + // With row selection, the default section highlighting bolds every column header whenever a + // row is selected. + horizontalHeader()->setHighlightSections(false); + setShowGrid(false); + + setEditTriggers(QAbstractItemView::NoEditTriggers); + setSelectionBehavior(QAbstractItemView::SelectRows); + setSelectionMode(QAbstractItemView::SingleSelection); +} + + +void TriageTableView::setTriageModel(TriageTableModel* model, int sortColumn) +{ + m_model = model; + setModel(model); + + sortByColumn(sortColumn, Qt::AscendingOrder); + setSortingEnabled(true); + + m_busyOverlay = new QLabel(viewport()); + m_busyOverlay->setAlignment(Qt::AlignCenter); + m_busyOverlay->setAutoFillBackground(true); + m_busyOverlay->setContentsMargins(16, 8, 16, 8); + m_busyOverlay->hide(); + const auto updateOverlay = [this](bool) { + if (m_model->isSorting()) + m_busyOverlay->setText(QStringLiteral("Sorting…")); + else if (m_model->isFiltering()) + m_busyOverlay->setText(QStringLiteral("Filtering…")); + m_busyOverlay->setVisible(m_model->isSorting() || m_model->isFiltering()); + positionBusyOverlay(); + }; + connect(m_model, &TriageTableModel::filteringChanged, this, updateOverlay); + connect(m_model, &TriageTableModel::sortingChanged, this, updateOverlay); +} + + +void TriageTableView::fitColumn(int column, const std::vector& contents) +{ + const QFontMetricsF metrics(getMonospaceFont(this)); + // Room for the header's sort indicator. + const int padding = (int)metrics.horizontalAdvance(QStringLiteral(" ")); + qreal width = metrics.horizontalAdvance( + m_model->headerData(column, Qt::Horizontal, Qt::DisplayRole).toString()); + for (const auto& content : contents) + width = std::max(width, metrics.horizontalAdvance(content)); + setColumnWidth(column, (int)width + padding); +} + + +void TriageTableView::savePosition() +{ + const auto current = selectionModel()->currentIndex(); + const auto top = indexAt(QPoint(0, 0)); + m_model->saveRowIdentities(current.isValid() ? current.row() : -1, top.isValid() ? top.row() : -1); +} + + +void TriageTableView::restorePosition() +{ + const auto [selectedRow, topRow] = m_model->takeSavedRows(); + if (selectedRow >= 0) + selectionModel()->setCurrentIndex(m_model->index(selectedRow, 0), + QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + if (topRow >= 0) + scrollTo(m_model->index(topRow, 0), QAbstractItemView::PositionAtTop); +} + + +void TriageTableView::setFilter(const std::string& filter, FilterOptions options) +{ + m_model->setFilter(filter, options); +} + + +void TriageTableView::scrollToFirstItem() +{ + if (model()->rowCount() > 0) + { + QModelIndex top = indexAt(rect().topLeft()); + if (top.isValid()) + scrollTo(top); + } +} + + +void TriageTableView::scrollToCurrentItem() +{ + QModelIndex currentIndex = selectionModel()->currentIndex(); + if (currentIndex.isValid()) + scrollTo(currentIndex); +} + + +void TriageTableView::ensureSelection() +{ + QModelIndex current = selectionModel()->currentIndex(); + if (current.isValid() || model()->rowCount() == 0) + return; + + if (auto top = indexAt(rect().topLeft()); top.isValid()) + { + selectionModel()->select(top, QItemSelectionModel::ClearAndSelect); + setCurrentIndex(top); + } +} + + +void TriageTableView::activateSelection() +{ + // Return from the filter moves focus to the table rather than activating a row. Activating + // loads an image or navigates, which is too aggressive to trigger from the filter field. + // A selection is ensured so the table is immediately navigable with the arrow keys. + ensureSelection(); + closeFilter(); +} + + +void TriageTableView::resizeEvent(QResizeEvent* event) +{ + QTableView::resizeEvent(event); + if (m_busyOverlay) + positionBusyOverlay(); +} + + +void TriageTableView::positionBusyOverlay() +{ + m_busyOverlay->adjustSize(); + m_busyOverlay->move((viewport()->width() - m_busyOverlay->width()) / 2, + (viewport()->height() - m_busyOverlay->height()) / 2); +} + + +TriageTablePanel::TriageTablePanel(QWidget* parent, TriageTableView* table, + const QString& filterPlaceholder, QString rowNoun) + : QWidget(parent), m_table(table), m_rowNoun(std::move(rowNoun)) +{ + m_filterEdit = new FilterEdit(table); + m_filterEdit->setPlaceholderText(filterPlaceholder); + m_filterEdit->showRegexToggle(true); + connect(m_filterEdit, &FilterEdit::textChanged, this, &TriageTablePanel::applyFilter); + connect(m_filterEdit, &FilterEdit::optionsChanged, this, &TriageTablePanel::applyFilter); + + m_statusLabel = new QLabel(this); + m_statusLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + // Tabular numbers keep the status text from shifting as its numbers change. + QFont statusFont = m_statusLabel->font(); + statusFont.setFeature("tnum", 1); + m_statusLabel->setFont(statusFont); + + m_footerLayout = new QHBoxLayout; + m_footerLayout->addWidget(m_statusLabel); + m_footerLayout->setAlignment(Qt::AlignLeft); + + auto layout = new QVBoxLayout(this); + layout->addWidget(m_filterEdit); + layout->addWidget(m_table); + layout->addLayout(m_footerLayout); + + // Frees the loaded content once the tab has been hidden for this long. + constexpr int clearDelayMs = 60 * 1000; + m_clearTimer = new QTimer(this); + m_clearTimer->setInterval(clearDelayMs); + m_clearTimer->setSingleShot(true); + connect(m_clearTimer, &QTimer::timeout, this, &TriageTablePanel::clearContent); + + const auto model = m_table->triageModel(); + connect(model, &TriageTableModel::filteringChanged, this, [this](bool) { updateStatusLabel(); }); + connect(model, &TriageTableModel::sortingChanged, this, [this](bool active) { + updateStatusLabel(); + if (!active) + restoreWhenIdle(); + }); +} + + +void TriageTablePanel::applyFilter() +{ + const QString text = m_filterEdit->text(); + const FilterOptions options = m_filterEdit->getFilterOptions(); + if (options.testFlag(UseRegexOption)) + { + const QRegularExpression regex(text); + if (!regex.isValid()) + { + m_filterEdit->setRegexValidationError(regex.errorString()); + return; + } + } + + m_filterEdit->setRegexValidationError({}); + m_table->setFilter(text.toStdString(), options); +} + + +void TriageTablePanel::addFooterWidget(QWidget* widget) +{ + m_footerLayout->insertWidget(m_footerLayout->count() - 1, widget); +} + + +QAction* TriageTablePanel::addFilterToggle(const QString& iconPath, const QString& toolTip, + std::function onToggled) +{ + QPixmap offPixmap; + pixmapForBWMaskIcon(iconPath, &offPixmap); + QPixmap onPixmap; + pixmapForBWMaskIcon(iconPath, &onPixmap, m_filterEdit->palette().color(QPalette::Highlight), "filterOn"); + auto action = m_filterEdit->addAction(QIcon(offPixmap), QLineEdit::TrailingPosition); + action->setCheckable(true); + action->setToolTip(toolTip); + connect(action, &QAction::toggled, this, + [action, offIcon = QIcon(offPixmap), onIcon = QIcon(onPixmap), + onToggled = std::move(onToggled)](bool checked) { + action->setIcon(checked ? onIcon : offIcon); + onToggled(checked); + }); + return action; +} + + +QPushButton* TriageTablePanel::addSelectionButton(const QString& text) +{ + auto button = new QPushButton(text); + button->setEnabled(false); + const auto update = [this, button] { + button->setEnabled(m_table->selectionModel()->hasSelection()); + }; + connect(m_table->selectionModel(), &QItemSelectionModel::selectionChanged, button, update); + connect(m_table->triageModel(), &QAbstractItemModel::modelReset, button, update); + addFooterWidget(button); + return button; +} + + +void TriageTablePanel::setLoader(std::function loader) +{ + m_loader = std::move(loader); +} + + +void TriageTablePanel::setClearHandler(std::function handler) +{ + m_clearHandler = std::move(handler); +} + + +void TriageTablePanel::setBaselineCount(std::function baselineCount) +{ + m_baselineCount = std::move(baselineCount); +} + + +void TriageTablePanel::setViewVisible(bool visible) +{ + m_viewVisible = visible; + updateActive(); +} + + +void TriageTablePanel::setCurrentTabWidget(QWidget* current) +{ + m_tabCurrent = (current == this); + updateActive(); +} + + +void TriageTablePanel::updateActive() +{ + // The content is on screen only when the triage view is visible and this tab is current. + if (m_viewVisible && m_tabCurrent) + { + m_clearTimer->stop(); + if (!m_loadStarted && m_loader) + { + m_loadStarted = m_loader(); + // The loader owns the status label, for its progress text, until the load finishes. + m_loaderOwnsStatus = m_loadStarted; + } + } + else if (m_loadStarted && !m_clearTimer->isActive()) + { + // Begin the countdown from when the content first went off screen. Later transitions + // while still off screen must not extend it. + m_clearTimer->start(); + } +} + + +void TriageTablePanel::clearContent() +{ + if (!m_loadStarted) + return; + + // Clearing abandons any running sort, whose end signal must not consume the saved position. + m_restorePending = false; + if (m_clearHandler) + m_clearHandler(); + m_table->savePosition(); + m_table->triageModel()->clearRows(); + m_loadStarted = false; + m_loaderOwnsStatus = false; + m_statusLabel->setText(""); +} + + +void TriageTablePanel::resetContent() +{ + clearContent(); + updateActive(); +} + + +void TriageTablePanel::finishLoad() +{ + // Restore the saved position once the sort over the now complete content commits. + m_restorePending = true; + m_table->sortByColumn(m_table->horizontalHeader()->sortIndicatorSection(), + m_table->horizontalHeader()->sortIndicatorOrder()); + m_loaderOwnsStatus = false; + updateStatusLabel(); +} + + +void TriageTablePanel::updateStatusLabel() +{ + if (!m_loadStarted || m_loaderOwnsStatus) + return; + + const auto model = m_table->triageModel(); + if (model->isSorting()) + { + m_statusLabel->setText(QStringLiteral("Sorting…")); + return; + } + if (model->isFiltering()) + { + m_statusLabel->setText(QStringLiteral("Filtering…")); + return; + } + + const QLocale locale; + const auto shown = static_cast(model->rowCount(QModelIndex())); + if (!model->hasTextFilter()) + { + m_statusLabel->setText(QString("%1 %2").arg(locale.toString(shown), m_rowNoun)); + return; + } + const auto baseline = + static_cast(m_baselineCount ? m_baselineCount() : model->totalRowCount()); + m_statusLabel->setText( + QString("%1 / %2 %3").arg(locale.toString(shown), locale.toString(baseline), m_rowNoun)); +} + + +void TriageTablePanel::restoreWhenIdle() +{ + if (!m_restorePending) + return; + // A sort's end signal also fires when the sort is abandoned for a filter change or superseded + // by a newer sort, with the follow-up job starting just after, so the check is deferred one + // event-loop turn. Follow-up chains always end with a committed sort. + QTimer::singleShot(0, this, [this] { + const auto model = m_table->triageModel(); + if (!m_restorePending || model->isSorting() || model->isFiltering()) + return; + m_restorePending = false; + m_table->restorePosition(); + }); +} diff --git a/view/sharedcache/ui/triagetable.h b/view/sharedcache/ui/triagetable.h new file mode 100644 index 0000000000..d12813757d --- /dev/null +++ b/view/sharedcache/ui/triagetable.h @@ -0,0 +1,466 @@ +#pragma once + +#include + +#include "backgroundsortfilterrows.h" +#include "filter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +/*! Name lookups for a table's Image column, keyed by image header address and region start + address. +*/ +class ImageNameLookup +{ +public: + struct State + { + std::map imageNames; + // Names of regions not associated with an image, keyed by region start. + // regions within images should be resolved in imageNames instead. + std::map nonImageRegionNames; + // Full image paths for the Image column tooltip. + std::map imagePaths; + uint64_t maxAddress = 0; + }; + +private: + std::shared_ptr m_state = std::make_shared(); + +public: + // Build the lookups from the controller's images and regions. + void build(const SharedCacheAPI::SharedCacheController& controller); + + std::shared_ptr snapshot() const { return m_state; } + + static QString displayName( + const State& state, std::optional imageStart, uint64_t regionStart); + static QString tooltip(const State& state, std::optional imageStart, uint64_t regionStart); + + // The Image column display name: the image's base name, or the region's name for rows + // outside any image. + QString displayName(std::optional imageStart, uint64_t regionStart) const; + + // The Image column tooltip: the image's full path, or the region's name for rows outside + // any image. + QString tooltip(std::optional imageStart, uint64_t regionStart) const; + + // The widest text the Image column can display, for sizing it: the longest image name or + // non-image region name. + QString widestImageColumnText() const; + + // The end of the highest region, for sizing zero-padded address text. + uint64_t maxAddress() const { return m_state->maxAddress; } +}; + + +/*! A row that resolves to an image and region, so it can be displayed and ranked in the Image + column. `imageStart` is 0 when the row is outside any image; `regionStart` is 0 when it is + outside any region. +*/ +template +concept RowWithImageColumn = requires(const Row& row) { + { row.imageStart } -> std::convertible_to; + { row.regionStart } -> std::convertible_to; +}; + + +/*! A three-way ordering of rows by their Image-column display name (the image's base name, or the + region's name for rows outside any image), resolving each name to a dense integer rank once so + comparisons are integer-only rather than repeated string compares. +*/ +template +std::function ImageColumnOrdering( + const ImageNameLookup::State& names) +{ + struct NameEntry + { + const QString* name; + bool isImage; + uint64_t key; + }; + std::vector entries; + entries.reserve(names.imageNames.size() + names.nonImageRegionNames.size()); + for (const auto& [address, name] : names.imageNames) + entries.push_back({&name, true, address}); + for (const auto& [start, name] : names.nonImageRegionNames) + entries.push_back({&name, false, start}); + std::sort(entries.begin(), entries.end(), + [](const NameEntry& a, const NameEntry& b) { return *a.name < *b.name; }); + + struct NameRanks + { + std::unordered_map imageRanks; + std::unordered_map regionRanks; + }; + auto ranks = std::make_shared(); + int rank = -1; + const QString* previousName = nullptr; + for (const auto& entry : entries) + { + // Identical names share a rank, matching equality under name comparison. + if (!previousName || *entry.name != *previousName) + { + rank++; + previousName = entry.name; + } + (entry.isImage ? ranks->imageRanks : ranks->regionRanks)[entry.key] = rank; + } + + return [ranks](const Row& a, const Row& b) { + // Unknown names rank first like the empty string. + const auto rankOf = [&ranks](const Row& row) { + if (row.imageStart) + { + if (auto it = ranks->imageRanks.find(row.imageStart); it != ranks->imageRanks.end()) + return it->second; + } + if (auto it = ranks->regionRanks.find(row.regionStart); it != ranks->regionRanks.end()) + return it->second; + return -1; + }; + return rankOf(a) <=> rankOf(b); + }; +} + + +/*! Shared portion of the triage view's flat table models: the filter state and the text matching + common to the tables. Row storage lives in the typed subclass `TriageTableRowsModel`. +*/ +class TriageTableModel : public QAbstractTableModel +{ +Q_OBJECT +protected: + struct FilterParams + { + std::string text; + FilterOptions options; + QRegularExpression regex; + bool matchImageNames; + }; + + // The filter state, captured on the GUI thread for the worker-thread predicate factories. + struct FilterSnapshot + { + std::string text; + FilterOptions options; + bool matchImageNames; + }; + + QFont m_font; + + // Rendered width of the highest cache address, for zero-padded address display. + uint32_t m_addressWidth = 16; + + std::string m_filterText; + FilterOptions m_filterOptions; + bool m_matchImageNames = false; + + // Active sort keys, most significant first. + std::vector> m_sortKeys; + + explicit TriageTableModel(QWidget* parent); + + // Promote `column` to the most significant sort key, keeping the rest as lower-priority keys. + void promoteSortColumn(int column, Qt::SortOrder order); + + FilterSnapshot filterSnapshot() const { return {m_filterText, m_filterOptions, m_matchImageNames}; } + + // Materialize a snapshot's matching state. Call once per work chunk on the worker threads: + // QRegularExpression::match takes the instance's mutex, so a shared instance serializes them. + static FilterParams MakeFilterParams(const FilterSnapshot& snapshot); + + // Whether the filter matches the row's primary text, its rendered address, or its image name. + // Pass an empty image name when image name matching is disabled. + static bool MatchesText(const FilterParams& params, const std::string& text, uint64_t address, + uint32_t addressWidth, const QString& imageName); + + // Re-apply the filter state to the rows. + virtual void applyFilter() = 0; + +public: + uint32_t addressWidth() const { return m_addressWidth; } + + void setFilter(const std::string& text, FilterOptions options); + + // Whether the filter also matches against the Image column. Re-applies any active filter. + void setMatchImageNames(bool match); + + bool hasTextFilter() const { return !m_filterText.empty(); } + + virtual bool isFiltering() const = 0; + virtual bool isSorting() const = 0; + + // Count of all rows fetched so far, ignoring any filter. + virtual size_t totalRowCount() const = 0; + + // Drop all rows, freeing their storage, while retaining the filter settings. + virtual void clearRows() = 0; + + // Capture the identities of the given display rows for a `takeSavedRows` lookup after a + // content reload. Pass -1 to leave a slot empty. + virtual void saveRowIdentities(int selectedRow, int topRow) = 0; + // Consume the identities captured by `saveRowIdentities`, returning their current display + // rows as {selected, top}, with -1 where a row is no longer displayed. + virtual std::pair takeSavedRows() = 0; + +Q_SIGNALS: + // Emitted when background filtering starts and finishes. + void filteringChanged(bool active); + // Emitted when background sorting starts and finishes. + void sortingChanged(bool active); +}; + + +/*! Typed row storage for triage table models: rows sorted and filtered on the worker pool, plus + the row identity bookkeeping that restores positions across a content reload. +*/ +template +class TriageTableRowsModel : public TriageTableModel +{ +public: + using Predicate = typename BackgroundSortFilterRows::Predicate; + using PredicateFactory = typename BackgroundSortFilterRows::PredicateFactory; + using Comparator = typename BackgroundSortFilterRows::Comparator; + + int rowCount(const QModelIndex& parent) const override + { + Q_UNUSED(parent); + return static_cast(m_rows.displayCount()); + } + + const Row& rowAt(int row) const { return m_rows.displayAt(row); } + + void appendRows(std::vector rows) { m_rows.append(std::move(rows)); } + + bool isFiltering() const override { return m_rows.filtering(); } + bool isSorting() const override { return m_rows.sorting(); } + + size_t totalRowCount() const override { return m_rows.totalCount(); } + + void clearRows() override { m_rows.clear(); } + + void saveRowIdentities(int selectedRow, int topRow) override + { + m_savedSelection = selectedRow >= 0 ? std::optional(rowAt(selectedRow)) : std::nullopt; + m_savedTopRow = topRow >= 0 ? std::optional(rowAt(topRow)) : std::nullopt; + } + + std::pair takeSavedRows() override + { + int selectedRow = -1; + int topRow = -1; + const int count = static_cast(m_rows.displayCount()); + for (int row = 0; row < count && (selectedRow < 0 || topRow < 0); row++) + { + const Row& candidate = m_rows.displayAt(row); + if (selectedRow < 0 && m_savedSelection && rowsEquivalent(candidate, *m_savedSelection)) + selectedRow = row; + if (topRow < 0 && m_savedTopRow && rowsEquivalent(candidate, *m_savedTopRow)) + topRow = row; + } + m_savedSelection.reset(); + m_savedTopRow.reset(); + return {selectedRow, topRow}; + } + + // Sort by `column` as the most significant key, keeping previously clicked columns as + // lower-priority keys so successive sorts compose into a multi-key order. + void sort(int column, Qt::SortOrder order) override + { + promoteSortColumn(column, order); + struct SortKey + { + KeyOrdering ordering; + bool descending; + }; + + std::vector keys; + keys.reserve(m_sortKeys.size()); + for (const auto& [keyColumn, keyOrder] : m_sortKeys) + { + if (auto ordering = orderingForColumn(keyColumn)) + keys.push_back({std::move(ordering), keyOrder == Qt::DescendingOrder}); + } + + m_rows.sort([keys = std::move(keys), finalComparator = m_finalComparator]( + const Row& a, const Row& b) { + for (const auto& key : keys) + { + const std::strong_ordering ordering = key.ordering(a, b); + if (ordering != 0) + return key.descending ? ordering > 0 : ordering < 0; + } + return finalComparator(a, b); + }); + } + +protected: + explicit TriageTableRowsModel(QWidget* parent) : + TriageTableModel(parent), + m_rows({ + std::bind_front(&TriageTableRowsModel::beginResetModel, this), + std::bind_front(&TriageTableRowsModel::endResetModel, this), + std::bind_front(&TriageTableRowsModel::beginInsertRows, this, QModelIndex()), + std::bind_front(&TriageTableRowsModel::endInsertRows, this), + std::bind_front(&TriageTableRowsModel::filteringChanged, this), + std::bind_front(&TriageTableRowsModel::sortingChanged, this), + }) + {} + + // Whether two rows are the same row, for position save and restore. + virtual bool rowsEquivalent(const Row& a, const Row& b) const = 0; + + // The ascending three-way ordering for a column, or null if the column is not sortable. + using KeyOrdering = std::function; + virtual KeyOrdering orderingForColumn(int column) const = 0; + + // The final tiebreak appended after the sort keys so the order is total and reproducible. + Comparator m_finalComparator; + + // This base member is destroyed after derived model members, so predicates and comparators + // passed to it must capture worker-safe snapshots rather than reading derived model state. + BackgroundSortFilterRows m_rows; + // Rows captured by `saveRowIdentities` for restoring positions across a content reload. + std::optional m_savedSelection; + std::optional m_savedTopRow; +}; + + +/*! Shared behavior for the triage view's tables: dense monospace styling, filter target + plumbing, the busy overlay, and position save and restore across content reloads. +*/ +class TriageTableView : public QTableView, public FilterTarget +{ +Q_OBJECT + TriageTableModel* m_model = nullptr; + QLabel* m_busyOverlay = nullptr; + + void positionBusyOverlay(); + +public: + TriageTableModel* triageModel() const { return m_model; } + + // Capture the identities of the selected row and the first visible row ahead of a content + // reload. + void savePosition(); + // Re-select and re-scroll to the rows captured by `savePosition`, where still displayed. + void restorePosition(); + + void setFilter(const std::string& filter, FilterOptions options) override; + + void scrollToFirstItem() override; + void scrollToCurrentItem() override; + void ensureSelection() override; + void activateSelection() override; + +protected: + explicit TriageTableView(QWidget* parent); + + // Attach the model, set the column the table initially sorts by, and finish the setup that + // depends on the model. Must be called exactly once from the derived constructor. + void setTriageModel(TriageTableModel* model, int sortColumn); + + // Fit the column to the widest of its header and `contents` in the monospace font, plus room + // for the header's sort indicator. The column remains user-resizable. + void fitColumn(int column, const std::vector& contents); + + // Fit each fixed-content column to its possible contents. Called when the name sources + // change. Only the last column stretches. + virtual void applyDefaultColumnWidths() = 0; + + void resizeEvent(QResizeEvent* event) override; +}; + + +/*! A triage tab hosting an expensive table: a filter field above the table and a footer with a + status label. The content loads when the tab first comes on screen, is cleared after the tab + has been off screen for a delay, and the table's position is saved and restored across the + reload. +*/ +class TriageTablePanel : public QWidget +{ +Q_OBJECT + TriageTableView* m_table; + FilterEdit* m_filterEdit; + QHBoxLayout* m_footerLayout; + QLabel* m_statusLabel; + QTimer* m_clearTimer; + QString m_rowNoun; + + std::function m_loader; + std::function m_clearHandler; + std::function m_baselineCount; + + bool m_loadStarted = false; + bool m_viewVisible = false; + bool m_tabCurrent = false; + bool m_restorePending = false; + bool m_loaderOwnsStatus = false; + + void updateActive(); + void applyFilter(); + void updateStatusLabel(); + void restoreWhenIdle(); + +public: + // `rowNoun` names the rows in the footer's counts, e.g. "symbols". + TriageTablePanel(QWidget* parent, TriageTableView* table, const QString& filterPlaceholder, + QString rowNoun); + + TriageTableView* table() const { return m_table; } + FilterEdit* filterEdit() const { return m_filterEdit; } + // The loader owns the status label, for its progress text, until `finishLoad`. + QLabel* statusLabel() const { return m_statusLabel; } + + // Insert a widget ahead of the status label in the footer. + void addFooterWidget(QWidget* widget); + + // Add a checkable icon action to the filter field, highlighting the icon while checked. + QAction* addFilterToggle(const QString& iconPath, const QString& toolTip, + std::function onToggled); + + // Add a footer button that is enabled only while the table has a selection. + QPushButton* addSelectionButton(const QString& text); + + // Starts loading the table's content when the tab first comes on screen. Returns false if + // loading cannot begin yet, retrying on the next activation. + void setLoader(std::function loader); + // Invoked before the rows are cleared, to stop and free the owner's loader state. + void setClearHandler(std::function handler); + // Count of rows eligible for display ignoring any text filter, the denominator of the + // footer's filtered count. Defaults to the model's total row count. + void setBaselineCount(std::function baselineCount); + + // Track whether the triage view is on screen and which triage tab is current. + void setViewVisible(bool visible); + void setCurrentTabWidget(QWidget* current); + + // Call when the load has delivered every row: applies the selected sort, restores the saved + // position once it commits, and returns the status label to the panel. + void finishLoad(); + + // Discard the content immediately and reload it if the tab is on screen. + void resetContent(); + + // Free the content. The filter widgets keep their state and the position is saved, so a + // reload reproduces the same view. + void clearContent(); +};