Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ open the listed source, and run the listed check. Do not read unrelated document
| If the task is about | Read | Code | Narrowest check |
|---|---|---|---|
| Query parsing, results, address box, autocomplete | [design](docs/design.md#navigation-and-search) | [model_search.cpp](src/model_search.cpp), [model_tokenizer.h](src/model_tokenizer.h), [app_search.h](src/app_search.h), [model_index_summary.cpp](src/model_index_summary.cpp) | `/test:*search*` |
| Indexing, the database, thumbnails, hydration | [implementation](docs/implementation.md#index-search-and-database) | [model_index.cpp](src/model_index.cpp), [model_db.cpp](src/model_db.cpp), [model_postings.h](src/model_postings.h) | `/test:*index*`, `/test:*thumbnail*` |
| Indexing, the database, thumbnails, hydration | [implementation](docs/implementation.md#index-search-and-database) | [model_index.cpp](src/model_index.cpp), [model_db.cpp](src/model_db.cpp), [model_postings.h](src/model_postings.h), [model_search_index.cpp](src/model_search_index.cpp) | `/test:*index*`, `/test:*thumbnail*` |
| Collection membership, duplicates, presence | [collections](docs/collections.md) | [model_index.cpp](src/model_index.cpp), [model_index_duplicates.cpp](src/model_index_duplicates.cpp), [app_settings.cpp](src/app_settings.cpp), [model_related.h](src/model_related.h) | `/test:*duplicate*`, `/test:*presence*` |
| Zoom, pan, the navigator, panorama projection | [zoom](docs/zoom.md) | [model_zoom.h](src/model_zoom.h), [view_media.h](src/view_media.h), [ui_panorama.h](src/ui_panorama.h) | `/test:*zoom*`, `/test:*panorama*` |
| Places, distance, map, visits, tiles | [locations](docs/locations.md) | [model_locations.cpp](src/model_locations.cpp), [model_visits.cpp](src/model_visits.cpp), [ui_map_common.h](src/ui_map_common.h), [ui_globe.h](src/ui_globe.h), [model_tile_cache.cpp](src/model_tile_cache.cpp) | `/test:*location*`, `/test:*visit*`, `/test:*globe*` |
Expand Down
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ set(DIFFRACTOR_PORTABLE_SOURCES
src/model_items.cpp
src/model_movie.cpp
src/model_search.cpp
src/model_search_index.cpp
src/model_tile_cache.cpp
src/render_charts.cpp
src/render_globe.cpp
Expand Down
2 changes: 1 addition & 1 deletion cmake/DiffractorLib8bit.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
include_guard(GLOBAL)
include(FetchContent)

set(DIFFRACTOR_LIB8BIT_REVISION 745ac7111a8e7cc7701ff9f5e77b2e75faaf5085)
set(DIFFRACTOR_LIB8BIT_REVISION db38a1b5c79e64e9f64ad0026c6dddac5ff31fc2)

if (NOT DEFINED FETCHCONTENT_SOURCE_DIR_LIB8BIT AND EXISTS "${CMAKE_SOURCE_DIR}/../lib8bit/CMakeLists.txt")
set(FETCHCONTENT_SOURCE_DIR_LIB8BIT "${CMAKE_SOURCE_DIR}/../lib8bit" CACHE PATH
Expand Down
2 changes: 1 addition & 1 deletion cmake/DiffractorLibdesktop.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
include_guard(GLOBAL)
include(FetchContent)

set(DIFFRACTOR_LIBDESKTOP_REVISION dff36833d1c7b9207e3bacf8d084933fc44945b3)
set(DIFFRACTOR_LIBDESKTOP_REVISION 38a23099e761082346b3a39978bd3613b2c2aca2)

if (NOT DEFINED FETCHCONTENT_SOURCE_DIR_LIBDESKTOP AND EXISTS "${CMAKE_SOURCE_DIR}/../libdesktop/CMakeLists.txt")
set(FETCHCONTENT_SOURCE_DIR_LIBDESKTOP "${CMAKE_SOURCE_DIR}/../libdesktop" CACHE PATH
Expand Down
2 changes: 1 addition & 1 deletion cmake/DiffractorLibfiles.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
include_guard(GLOBAL)
include(FetchContent)

set(DIFFRACTOR_LIBFILES_REVISION 4481ded34629ec203400fcf1b0ecbdf8510b471c)
set(DIFFRACTOR_LIBFILES_REVISION 27de9d9fb67d7be7fcf9ff8a9f87dad5a91ae3eb)

if (NOT DEFINED FETCHCONTENT_SOURCE_DIR_LIBFILES AND EXISTS "${CMAKE_SOURCE_DIR}/../libfiles/CMakeLists.txt")
set(FETCHCONTENT_SOURCE_DIR_LIBFILES "${CMAKE_SOURCE_DIR}/../libfiles" CACHE PATH
Expand Down
12 changes: 12 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ Address editing keeps a committed value, a typed draft, and an optional completi
preview completions without replacing the draft. Tab accepts into the draft; Enter commits; Escape
first restores the draft from a preview, then restores the address captured when editing began.

Each prediction shows how many items choosing it lists, and of two suggestions of one kind, the one
that finds more ranks higher. A suggestion drawn from the collection's own words, tags, places, or
properties is not offered when it would find nothing. What was typed is always offered first, and
recent searches keep their order. A count of none, or one not ready in time, shows nothing; the list
never waits for a count.

History entries retain query and selection. Back and Forward restore rather than rewrite them, and
move the history position only after the target navigation is accepted; a refused or unavailable
target leaves the current entry and the next Back/Forward target unchanged. Navigating after Back
Expand All @@ -174,6 +180,12 @@ Navigation from a map, chart, breakdown, summary, or group header changes the qu
sorting, and filters remain user-owned. Filtering removes hidden items from selection; removing the
filter does not reselect them.

A sidebar row's total is the number of items clicking it lists. Hovering a row also says how many of
the items shown it holds, when the index can say. A tag or label row that would list nothing is not
shown; rating rows always are, because together they are one scale. Predictions and sidebar rows
have no item target, as the owner decided: they navigate, and their counts describe what navigating
lists.

## Selection And Viewing

Plain click selects one item; Ctrl toggles; Shift extends from the focus anchor; Ctrl+Shift combines
Expand Down
5 changes: 4 additions & 1 deletion docs/file-io.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,17 @@ rather than copied and then rewritten. Other formats are copied, then updated. I
replacements. Temporary paths are cleaned on every bounded failure path.

A JPEG XL's metadata is updated by libfiles like any other format's, its compressed boxes read with
Diffractor's Brotli; one made from a JPEG keeps the boxes it gives that JPEG back from at their size.
libfiles' bounded Brotli decoder; one made from a JPEG keeps the boxes it gives that JPEG back from at their size.
Its picture is never rewritten in place: rotating or editing one is not offered, since a re-encode would
lose what it was encoded with. A pixel write that produces one - Convert, Save As, a shared copy -
reads the packet as an update would, applies the edit and, from another format, what the source shows
that its packet lacks, and encodes it into the file's own boxes beside the source's EXIF; no update
follows. The codestream holds the orientation the file ends with, which the EXIF is made to agree with.
A JPEG XL moved unchanged is copied as it is.

Encoded JPEG, PNG, WebP and JPEG XL buffers transfer ownership from libfiles
into the displayed image's blob without a second encoded-buffer copy.

Pasted clipboard bitmaps follow the same publication rule: the encoder writes an owned temporary
file off the UI thread, then publishes it to the chosen auto-numbered destination only after the
stream and encoder commit successfully.
Expand Down
67 changes: 62 additions & 5 deletions docs/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,27 @@ through `index_items`, scan/database workers update per-file atomics in place, a
lets folder replacement reject a node built before an in-place update. The revision protects the
folder contents as a set of per-file metadata/search-presence/cache fields; immutable replacement
alone is insufficient because scanning a whole folder for each individual metadata update would
clone large folder vectors and move file scanning back under publication contention.
clone large folder vectors and move file scanning back under publication contention. A second
counter, the search revision, rises with every in-place update except those to what is derived from an
item's bytes - duplicates, checksums and picture hashes - so the search index can tell a folder whose
captured contents changed from one that only gained a derived value it reads live; one counter cannot
serve both, because replacement must still reject a node built before a derived update.
Indexing progress is an immutable snapshot owned by the index worker and atomically published for
UI/sidebar readers; paired total/remaining counters must be read together, and handing every paint
call through the index queue or posting a UI callback for each scanned file would either block Paint
on Source work or create unbounded invalidation traffic.
`view_state` owns the visit timeline on the UI thread, while `_visits_generation` is atomic only so
the location worker can observe superseded visit derivations through `df::cancel_token`; the worker
still publishes a complete detached `visit_timeline` back to the UI thread before the UI-owned value
is replaced.
is replaced. The address box's `_count_version` is atomic for the same reason: each keystroke raises
it on the UI thread, and the auto-complete worker observes it through `df::cancel_token` to stop
counting a superseded pass, which then publishes nothing.

The search index is an index cache published as one immutable snapshot. Its own queue builds it with
no lock held, `index_state` swaps the pointer under the summary lock, and readers copy the pointer
under the same lock and evaluate with none held. An index updated in place would need a lock across
every evaluation that indexing writers then wait on; a snapshot plus the per-folder revisions it
records keeps readers and indexing apart.

Three process-wide values are also published to every thread. A translation - and the extra plural
forms of a plural text - is an atomic pointer into storage `app_text_t` only ever appends to: the UI
Expand All @@ -143,8 +155,8 @@ publish before extraction completes.
Search parses into exact matchers and conservative candidate prefilters. Negation and OR never create
a prefilter that could omit an exact result. Query generations prevent stale result publication.
Folder-scoped searches validate their filesystem scope; index-wide searches answer from cached
records. In-app file operations report every changed source and destination folder so search refresh
does not depend on a live watcher.
records, through the [search index](#search-index) where it is current. In-app file operations report
every changed source and destination folder so search refresh does not depend on a live watcher.

The summary keeps authoritative tag histograms and word counts for every indexed tag. Tag-companion
recommendations are auxiliary typeahead hints and are bounded per pathological item: when one item
Expand All @@ -160,6 +172,50 @@ items, and presence consume the same duplicate relation so they cannot disagree
Related-item collection scores the index against one anchor and keeps bounded best results per
relation. Counting and display share the same collector and stable path tie-breaks.

### Search Index

The search index is an immutable in-memory reverse index of the collection, read from the cached
records rather than the database, so it needs no schema change and no re-index. Items are numbered in
path order. Each distinct name, free-text value, formatted value, split part, and tag part maps to the
items holding it, and a trigram index over the distinct text values suggests which of them hold a
free-text term. A search's terms become item sets combined by `combine_search_terms`, the steps the
matcher applies to one item. The matcher's own predicate confirms every suggestion. Rating and label
terms read nothing of an item but that property, so items are grouped by its value and `match_term`
answers once per group. Date terms compare only the day each of an item's date keys falls on, so
`date_term_keys_of` and `date_term_found`, which the matcher uses too, decide them with each day
compared once. A term no table can answer, such as a size comparison, is answered by `match_term` over
each item's captured metadata. An answer from the index is therefore the answer a scan gives, and the
index tests compare the two over about 130 searches.

Each folder entry keeps the folder node and search revision it was read from. A folder changed since
is scanned on top of the snapshot. Duplicates, checksums, picture hashes and presence are always read
live, so recording them leaves a folder current. A search uses the snapshot while at least half the
collection is unchanged and otherwise scans, as it would without one. Formatted values depend on the
language and number format, and date keys on the time zone and, through what counts as a valid date,
the day; when one changes, those terms are answered item by item until the next read, and maintenance
reads the collection again on a new day.

Reading never delays indexing or a search. It runs on its own `search_index` queue, never
`index_compute`, a debounced second after the summary and duplicate-prediction passes, and only once
changes since the last read pass a tolerance that grows with the collection, or leave the snapshot one
no search would use. It does not start while indexing or scanning runs and abandons when either
starts; a snapshot too stale to use is then dropped so it stops holding replaced records. Nothing is
locked while reading or evaluating: publication swaps a pointer under the summary lock and releases the
old snapshot after it. A collection whose snapshot would pass a 1 GiB ceiling keeps none and scans.
Each snapshot published asks the sidebar to count again, since it may have counted by scanning. The
performance report's Search index rows show how many searches it answered and how long reads took and
how much memory they hold.

Sidebar rows and address-box predictions are counted through `search_counter`, which lists the
collection once for a whole pass. Its evaluator keeps each term's items and the items through each
presence requirement for the pass, so the searches of a pass share them, and a count reads only the
folders the snapshot cannot answer for. A sidebar pass counts from the snapshot only while few items
have changed since it was read, since every row would read each of them; past that it counts as it did
without one. Prediction counts are budgeted per keystroke and stopped by the next one. When the
snapshot cannot answer cheaply - there is none yet, too many items changed, the search walks a folder
tree, or it is a related search - the count is left out rather than scanned, and a folder search with a
term is never counted, since that would read the folder's unread files on every keystroke.

### Thumbnail Pipeline

Layout dimensions, encoded thumbnail, decoded near-viewport surface, and GPU texture are separate
Expand Down Expand Up @@ -246,7 +302,8 @@ repository lint, build, embedded tests, and translation validation. Focused test
state.
- [model_index.cpp](../src/model_index.cpp), [model_search.cpp](../src/model_search.cpp),
[model_postings.h](../src/model_postings.h), and [model_db.cpp](../src/model_db.cpp): index, search,
candidate sets, and persistence.
candidate sets, and persistence; [model_search_index.cpp](../src/model_search_index.cpp): the
search index snapshot and its evaluation.
- [model_index_duplicates.cpp](../src/model_index_duplicates.cpp): duplicate prediction and presence;
[model_index_summary.cpp](../src/model_index_summary.cpp): the summary, its vocabulary, and
auto-complete.
Expand Down
4 changes: 2 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ One file per subject, matching the `src/` prefixes in [implementation](implement
| [test_metadata.cpp](../src/test_metadata.cpp) | `register_metadata_tests` | `metadata*`, `model_property`, `model_dates`, `model_tags`: EXIF, IPTC, XMP, ICC, per-format metadata scanning, the date pack and its authority table, the panorama declaration, sidecars, tag sets, property presentation |
| [test_media_edit.cpp](../src/test_media_edit.cpp) | `register_media_edit_tests` | The metadata write path and the image editing model: in-place and staged edits, Windows shell tags, orientation, crop, perspective, temperature |
| [test_av.cpp](../src/test_av.cpp) | `register_av_tests` | `av*`: probing, seeking, read-ahead bounds, hover preview, audio buffers, the visualizer, SID tunes, subtitles, session lifetime |
| [test_index.cpp](../src/test_index.cpp) | `register_index_tests` | `model_index`, `model_db`, `model_postings`: indexing, the inverted and trigram indexes, the database, the thumbnail pipeline, duplicates and presence, stale-result discarding, hydration |
| [test_index.cpp](../src/test_index.cpp) | `register_index_tests` | `model_index`, `model_db`, `model_postings`, `model_search_index`: indexing, the inverted and trigram indexes, the search index and its agreement with a scan, the database, the thumbnail pipeline, duplicates and presence, stale-result discarding, hydration |
| [test_search.cpp](../src/test_search.cpp) | `register_search_tests` | `model_search`, `model_related`, `app_match`: query parsing, term matching, scopes, negation, exclusion, related items, prediction |
| [test_locations.cpp](../src/test_locations.cpp) | `register_location_tests` | `model_locations`, `model_visits`, `ui_map*`, `ui_globe`, `model_tile_cache`: the gazetteer, place naming, map areas and geometry, the globe's projection and framing, visits, the timeline, the map tile cache. See [locations](locations.md) |
| [test_movie.cpp](../src/test_movie.cpp) | `register_movie_tests` | `model_movie`: the Movie document -- derived output geometry, transition and duration arithmetic, timeline edits and undo, what entering Movie does with the timeline it holds, the OpenTimelineIO project file, the Movie Maker reader, finding a moved source among same-named files. See [movie](movie.md) |
Expand Down Expand Up @@ -94,7 +94,7 @@ Where a decision is trapped inside a method that also performs it, extract the d
| `should_resume_at` / `position_to_save` ([av_player.h](../src/av_player.h)) | the resume window and what a close saves |
| `calc_playback_advance` ([util_interfaces.h](../src/util_interfaces.h)) | slideshow, repeat and continue-with-next, and how they interact |
| `search_edit_session` ([app_search.h](../src/app_search.h)) | the address box editing session: draft, preview, two-stage Escape, Tab, Enter |
| `make_search_auto_complete` ([app_search.h](../src/app_search.h)) | the address bar's completion results: content, ranking, de-duplication, the cap, latest-wins |
| `make_search_auto_complete` ([app_search.h](../src/app_search.h)) | the address bar's completion results: content, counts, ranking, de-duplication, the cap, latest-wins |
| `default_keyboard_accelerators` ([app_commands.h](../src/app_commands.h)) | the shipped key bindings, so a collision can be seen |

A test that drives the completion strategy must run the pass on a real worker thread: `index_state::auto_complete_words` asserts it is not on the UI thread, which is the contract the shipped code keeps. `run_completion_worker` in [test_app.cpp](../src/test_app.cpp) models the hop with a `deferred_async_strategy`; running it inline trips the assertion rather than passing. That is also what makes the latest-wins test possible, because it can publish two passes out of order.
Expand Down
Loading
Loading