Introduction
Falcon is a rebuild of the Eagle asset manager in Rust, written to learn how Eagle works.
Each chapter in the first part describes one piece of Eagle. It covers what Eagle does, with every claim marked observed or inferred, and then how falcon implements it.
- Observed claims come from Eagle’s own files: a real library on disk, or REA analysis of
app.asar. - Inferred claims are conclusions drawn from those files that haven’t been checked directly.
Falcon is written from that behaviour. It doesn’t copy Eagle’s code.
The second part, The desktop app, describes falcon’s desktop app: its window, viewers, keys, preferences, themes and how it opens and saves libraries.
Status
Falcon is early alpha and unsigned. PARITY.md compares it with Eagle 4.0.0 feature by feature, checked against falcon’s code. Of Eagle’s 912 features outside the online set, 905 are done and 7 are accepted differences; none is partly done or missing. That is about 99% done. Another 32 need an online service, an account or Eagle’s plugin system and are counted separately. ROADMAP.md lists what is left and what comes later.
Falcon handles exactly Eagle’s set of file formats. FORMATS.md lists each one and its status. Falcon has no plugin system: Actions and Export in Format are built into the app, and PLUGINS.md lists the plugins Eagle provides that falcon could build later.
Working today
- Import with thumbnails and colour palettes, folders with their structure, paste, drag and drop, and screen capture.
- Folders, tags with a tag picker, smart folders, Quick Access, ratings, notes, undo and batch editing.
- Keyword, filter and colour search, with marquee selection in four layouts.
- Viewers for images at full resolution, video (with an FFmpeg fallback that includes AV1), audio, fonts, a text editor, PDFs with their reader tools, interactive 3D models with textures, normal, bump and emissive maps and transparency, full IFC through web-ifc, PSD and AI at full resolution, and live web pages and MHTML.
- Preferences panes, the interface in eight languages (machine-written, waiting for a native read), sound effects, Actions, and Export in Format.
- The local HTTP API and the command-line tool.
Not there yet
- Some File ▸ New templates, normal and bump maps on 3D models, and a check on screen of some newer features (see ROADMAP.md).
- Eagle’s plugins.
- Signed releases, and the parts of the app that need macOS on Windows and Linux (see Windows).
Running the docs
scripts/docs.sh
This builds the API docs with cargo doc and serves the book at http://localhost:3000, reloading on changes.
Workspace
| Crate | Purpose |
|---|---|
falcon-library | Reading and writing .library folders: import, thumbnails, palettes, metadata, editing, search, smart folders, duplicates, undo, backups and .eaglepack files |
falcon-server | Eagle’s local HTTP API |
falcon-app | The desktop app, built with egui |
falcon-video | Probing, decoding and playing video through AVFoundation, falling back to falcon-ffmpeg |
falcon-ffmpeg | The fallback video decoder and player over an LGPL FFmpeg opened at run time (see Video playback) |
falcon-pdf | PDF pages, text, search and selection through PDFKit |
falcon | Command-line tool: new, import, folder, info, backup, export, import-pack, serve |
Build a launchable Falcon.app with scripts/bundle.sh.
Library format
An Eagle library is a folder ending in .library. All state is plain JSON next to the files themselves. There is no database.
Assets.library/
├── metadata.json folder tree and library-wide settings
├── mtime.json item id → modification time, plus "all"
├── tags.json historyTags, starredTags
├── saved-filters.json
├── backup/ JSON snapshots
├── Icon empty file (the macOS custom-icon marker)
├── icon.png the library's own icon (optional)
└── images/
└── <ID>.info/
├── metadata.json one item
├── <name>.<ext> the original file
└── <name>_thumbnail.png only for some items
icon.png is the picture the library switcher shows beside the library’s name. Falcon’s Change Icon scales the chosen image so its shorter side is 64 px, saves it as PNG next to the library’s other files and renames it over icon.png, so an old icon is never left half written. Remove Icon deletes the file. The HTTP API serves the same file.
Library metadata.json (observed)
The top-level keys, in this order, are folders, smartFolders, quickAccess, tagsGroups, modificationTime and applicationVersion.
folders is a recursive tree. Eagle rebuilds every node in a fixed key order each time it saves, and leaves out keys whose value is unset. The fixture’s folders carry only the always-present keys.
| Key | Type | Notes |
|---|---|---|
id | string | See IDs |
name, description | string | description is "" when empty |
children | array | Child folders, in sidebar order |
modificationTime | integer | ms. Set when the folder is created or duplicated. Renames, icons, sorting, passwords and moves leave it alone (observed) |
tags | string array | Tags given to items added to the folder |
extendTags | string array | Runtime only. The folder’s tags plus its ancestors’, worked out on load. The renderer’s save includes it, but the writer deletes it before writing, so it shouldn’t appear on disk |
icon | string | Optional. A name from the icon picker (see below). No key means the default folder icon |
iconColor | string | Optional. red, orange, yellow, green, aqua, blue, purple or pink. No key means the default grey |
pinyin | string | Runtime only. A romanised name cached for search, which the writer also deletes |
password | string | Always present. "" when unset (see passwords below) |
passwordTips | string | Always present. The hint, "" when unset |
coverId | string | Optional. The item shown as the folder’s cover |
orderBy | string | Optional. The folder’s own sort (see below) |
sortIncrease | bool | Written with orderBy. true is ascending |
Sort keys (observed): IMPORT, MTIME, BTIME, NAME, EXT, FILESIZE, RESOLUTION, RATING, DURATION and RANDOM are in the folder menu. TAGS is in the main sort menu, and MANUAL (a hand-arranged order, kept per folder in each item’s order map) is honoured when present. No orderBy key means the folder follows the global sort. Choosing a sort sets sortIncrease to true if it was unset. Clearing it deletes both keys.
Icons (observed): the picker offers 161 names. Falcon lists them in falcon_library::model::FOLDER_ICONS, spelled exactly as Eagle stores them (including excalmation, airplan and rophy). Picking the first, blank entry deletes the key. A new subfolder takes its parent’s icon and iconColor. Falcon’s emoji folder icons are not part of the format: Eagle builds a style class from icon, so an emoji there would have no artwork. Falcon keeps them in its own app data, keyed by folder id, so another Mac or Eagle does not see them, and it never writes them to metadata.json.
Passwords (observed): password is btoa(password), plain base64 with no hashing. btoa reads each UTF-16 unit as one byte and throws on anything above U+00FF. Eagle doesn’t escape the text first, so a password with such characters can’t be set at all. é is stored as 6Q==, not as the base64 of its UTF-8 bytes. Unlocking checks atob(password) against the typed text. Removing a password deletes both keys from the in-memory folder, but the save writes password || "", so on disk a folder that never had a password and one whose password was removed look the same: "password":"","passwordTips":"". Falcon refuses empty and non-Latin-1 passwords.
Ordering: the array order is the sidebar order. Dragging a folder between siblings moves it in its parent’s children. Dropping a folder onto another one appends it to that folder’s children.
Sort by title (observed in code): a folder’s context menu has a “Sort By” submenu with four commands: title A→Z or Z→A, for the current level or for all levels. The current level is the clicked folder’s siblings, that is, its parent’s children (or the root list). All levels sorts the root list and then every children array in the tree. Eagle asks for confirmation, sorts the arrays in place and saves. No flag is stored, and modificationTime is left alone. The comparison lowercases both names and uses localeCompare with numeric ordering, so folder 2 comes before Folder 10. Z→A is the A→Z result reversed.
Falcon: Library::sort_subfolders(parent, SubfolderSort { descending, all_levels }) sorts parent’s subfolders (None for the root list), and with all_levels every level below them too. Pass the clicked folder’s parent for Eagle’s “current level”, and None with all_levels for its “all levels”. Names compare with search::natural_cmp, the comparison falcon also uses for title sorts. Unlike the ICU collation Eagle uses, it orders accented letters by code point (inferred to matter only for non-ASCII names). Eagle’s comparator calls an empty name equal to everything. Falcon sorts empty names first, because Rust’s sort needs a consistent order.
The writer (observed): the background process writing metadata.json deletes these runtime keys from every folder: covers, images, folders, newFolderName, editable, pinyin, __root, extendTags and $$hashKey. From smart folders it deletes only newFolderName, editable and $$hashKey. It then writes {folders, smartFolders, quickAccess, tagsGroups, modificationTime: Date.now(), applicationVersion}. It refuses to write, and reports a sync conflict, when the file’s modificationTime is newer than the one Eagle loaded. Falcon still models extendTags and pinyin so that a file holding them round-trips.
Names (observed): every save NFC-normalises folder, smart-folder and tag-group names. Renames are cut to 1024 UTF-16 units; falcon cuts at 1024 characters. Falcon normalises and cuts every name it writes. A rename drops any stale pinyin.
Tag groups (observed in code)
tagsGroups is an array in sidebar order. The fixture has none. Each save rebuilds every entry as:
| Key | Type | Notes |
|---|---|---|
id | string | Same generator as item IDs (time in base 36 plus 5 random characters) |
name | string | NFC-normalised. Renames are cut to 1024 characters |
tags | string array | Members in group order. Line breaks are stripped when tags are filed |
color | string | Optional, written only when set. Same palette as folder iconColor |
description | string | Optional, written whenever it isn’t undefined, so "" survives |
- Creating a group: a new group goes at the end. Creating one from selected tags leaves those tags in their other groups.
- Adding tags: adding through the tag manager moves each tag, taking it out of any other group, so a tag normally belongs to one group.
- Removing tags: taking tags out of a group leaves them on their items.
- Deleting a group: Eagle asks whether to delete the group’s tags from every item too.
- Reordering: the sidebar is drag-sortable, and the array follows it. The drag takes the group out of the array and inserts it at the drop index, which is counted after the removal. This is what falcon’s
reorder_tag_group(id, index)does. - Description: the tag manager shows an editable box under a selected group’s name. On blur, Eagle saves the box’s HTML if it changed: a trailing
<br>and any<div>wrappers are stripped, and&is turned back into&. Clearing the box writes"", so the key stays once it has been set. Groups made by the tag manager have nodescriptionkey until then. Falcon’sset_tag_group_description(id, text)writes the given string,""included. - Other tag edits: renaming or deleting a tag also updates every group.
None of these edits touch the folders’ modificationTime. The library’s top-level modificationTime is stamped when metadata.json is written.
tags.json (observed in code)
{"historyTags":[...],"starredTags":[...]}, compact, with no trailing newline. Eagle writes it five seconds after a change, through an atomic write.
historyTags: recently used tags, latest first, at most 120. Adding a tag (the tag picker, pasting tags, the inspector) moves it to the front; adding several moves each in turn, so the last one leads. Eagle’s imports with tags move them as a batch, so there the first one leads (falcon’s imports leave the history alone). A tag taken off its last item is dropped. Renaming or merging a tag renames it in place, or drops it when the new name is already listed (the same rule holds forstarredTagsand each tag group’stags); deleting a tag removes it. Clear Tag History empties the list.starredTags: starred tags, most recently starred first.
Tag groups are not in this file but in tagsGroups of the library metadata.json (see above).
saved-filters.json (observed in code)
A JSON array in menu order, [] in the fixture. Eagle writes it compact with JSON.stringify and no trailing newline. Each entry is {"name": …, "rule": {…}}, with no id and no timestamp, and Eagle finds entries by position. Saving a filter first re-reads the file, then appends. A rename ignores empty names. The menu is drag-sortable, and the array follows it.
rule is a deep copy of the filter panel’s rules, made by JSON.parse(JSON.stringify(defaults)) and then edited, so undefined values are dropped and integer-like keys come first (rating is written "0" to "5", and shape starts with "34","43","169","916"). keyword is added at the end when a search was active. Applying a filter replaces each top-level rule key, a shallow {...defaults, ...rule}. The tag and folder logic (OR, AND or EQUAL) is a panel setting that isn’t saved.
| Rule key | Shape | Falcon search::Filter |
|---|---|---|
type.includes, type.excludes | {ext or group: true} | types, exclude_types |
tag.includes, tag.excludes | string arrays | tags.includes, tags.excludes |
tag.no | bool | tags.empty |
folder.includes, folder.excludes | {id: folder object}. Only .id is read back. The key NoFolders means items in no folder | folders.includes, folders.excludes, and NoFolders in includes maps to folders.empty. NoFolders in excludes has no counterpart |
rating | {"0"…"5": bool}, 0 = unrated | ratings |
shape.portrait, landscape, square, panoramicPortrait, panoramicLandscape | bool | shapes |
shape.34, 43, 169, 916, custom (+ width, height) | bool, numbers | none yet |
color.value | [r, g, b], absent when off | color.rgb |
color.accuracy | number, default 20 | color.accuracy |
color.gray | bool | grayscale |
resolution.minW, maxW, minH, maxH | number or numeric string | width, height |
file.min, file.max, file.unit | number in kb or mb (× 1024 or × 1024², then parseInt) | size in bytes |
url, note | {has, no, keywords}. keywords is comma-separated, and all must match | url, note (TextFilter) |
annotation | {has, no, keywords} over the item’s comments | none yet |
import, mtime | today, yesterday, last7day … last365day and usingRange (bools), range ([start, end] local midnights in ms), type ("range" once a range is picked), model (the picker’s text), selectedMonths ({"YYYY/MM": true}). The panel has one range picker, so the range lives in import.range and Date Modified’s usingRange reads it from there | imported, modified (DateFilter: presets, range, months) |
btime (falcon only) | same shape as import, written only when Date Created is on; Eagle ignores the key | created |
duration | min, max (number or numeric string) in unit s, m or h | duration in seconds |
bpm | min, max | bpm |
camera | {camera name: true} | cameras |
font | {activated, deactivated} bools; activated wins | font (FontFilter) |
image, semantic | image search and natural-language search. image.itemId and semantic.value turn them on when not null or blank; falcon reads them as an item id and a description, from their names (inferred, no file by Eagle seen). Other fields are unknown and kept | SavedAiRules, applied by AI Search |
keyword | string | returned beside the filter |
Falcon’s SavedFilter::to_filter returns the filter, the keyword and the AI rules, each kept whole so that a save writes back the same bytes (SavedAiRules::write_to). SavedFilter::rule_from_filter builds the full default rule object with falcon’s rules filled in. It writes sizes in KB, and folder entries as {id, name}, since Eagle only reads id back. Durations go in the largest of h, m and s that keeps both bounds whole. A Date Imported or Date Modified range goes in import.range with import.type: "range", where Eagle reads both; only when the two rules have different ranges does falcon also write mtime.range, which it reads back first and Eagle ignores (Eagle then applies the imported range to both). A range with an unset end reads as (0, 0), which passes nothing, as in Eagle. Reading a font rule fills the FontFilter with the fonts in ~/Library/Fonts/EagleApp and in falcon’s own ~/Library/Fonts/Falcon (to_filter_with_fonts takes them from the caller); Eagle reads only the first, so a font activated by falcon counts as installed in falcon and not in Eagle. Both directions are falcon’s own reading of Eagle’s code, checked by unit tests against hand-built rules, not against a file Eagle wrote (inferred).
Item metadata.json (observed)
| Key | Type | Notes |
|---|---|---|
id | string | See IDs |
name | string | File name without the extension |
size | integer | Bytes |
btime, mtime | integer | Times in ms, taken from the original file |
ext | string | No leading dot |
tags, folders | string arrays | folders holds folder IDs |
isDeleted | bool | In the trash |
url, annotation | string | |
modificationTime | integer | Time the record changed, in ms |
removeThumbnail, noThumbnail | bool | Optional: present on 72 of the fixture’s 91 items |
height, width | integer | Pixels. Three items write width first |
lastModified | integer | ms |
palettes | array | {color: [r,g,b], ratio}. See palettes |
Files Eagle can’t preview. For items such as .tex, .md, .py, .csv and .epub files, Eagle leaves out height, width and palettes. It may also add keys such as noPreview and deletedTime before lastModified. Seen in real libraries: 1,456 of 2,632 items in one, 2 EPUBs in another.
Damaged records. A record can be unreadable, for example 604 zero bytes after a cloud sync went wrong (seen once in a real library).
Falcon treats every key but id as optional. It remembers each record’s key order and writes keys back in that order, and leaves out a field the record didn’t have while it still holds its default. New items use the order in the table. An unreadable record is skipped when the library opens: falcon lists it in Library::skipped, never writes to it, and save_to copies its folder unchanged. Checked on four real libraries (3,905 readable records): every record saves back byte for byte.
Comments (observed in code)
Region notes on images and timed notes on video and audio live in an optional comments array on the item. JavaScript adds the key the first time a comment is made, so it comes after the keys an item starts with. None of the fixture’s items has comments. Eagle’s filter panel calls these “annotations”. They are separate from the item’s annotation string, which is the note.
| Key | Type | Notes |
|---|---|---|
id | string | Same generator as item IDs |
x, y, width, height | number | Image regions only. Pixels of the original image, not fractions: Eagle scales the dragged box by image.width / displayed width. New boxes are rounded to integers. After a move or resize the values can be fractional. Boxes of 10 px or less in either direction are discarded |
duration | number | Timed notes only. Seconds into playback (currentTime, fractional). Eagle re-sorts the list by duration after adding one |
annotation | string | Starts "" for a region. It holds the innerHTML of an editable box (<br> for line breaks, & turned back into &) |
lastModified | integer | ms. Set on creation and not bumped when the text, position or size changes |
Every comment change saves the whole item, so the item’s own lastModified and mtime.json move as in any edit. Deleting the last comment leaves "comments":[].
Falcon models these as model::Comment. Item::comments() and Item::set_comments() read and write the key without moving it or any other key in extra. On Library, add_comment fills in the id and time, and keeps timed notes sorted (a new note goes after others at the same time). update_comment replaces a comment by id, and delete_comment removes one. Falcon re-serialises each comment in Eagle’s key order: id, region or duration, annotation, lastModified, then unknown keys.
Manual order (observed in code)
A folder sorted MANUAL keeps its hand-arranged order in each item’s optional order object: order[<folder id>] is a decimal string, such as "1789479235636.5". JavaScript adds the key the first time an item is dragged, so it comes after the keys the item started with. A new folder id goes at the end of the object.
- Sorting: the
MANUALsort compares these values as strings, largest first. An item with no entry (or an empty one) uses itsmodificationTimeturned into a string, so undragged items fall in import order, newest first.sortIncrease: falsereverses the list. As strings,"999"sorts above"1000". This never matters for real timestamps, which all have 13 digits. - Dragging: Eagle works in exact decimals (a big-number library). Divisions round to 20 places, with halves rounded away from zero. Values are written in the shortest form (
toPrecision()with no argument, so"2500", not"2500.0"). Dropping n items between neighboursprevandnextsetsmiddle = (prev + next) / 2andstep = (middle - next) / n. The last dragged item getsmiddle, and each earlier one getsmiddle + step × k, which moves towardsprev. Before the first item,previs the first item’s value + 1 (- 1 when descending). After the last item,nextis the last value - 1 (+ 1 when descending). A neighbour with no entry counts as itsmodificationTime. - Collisions: before placing, Eagle checks whether two of the other items share a value. If they do, every item i (counting from 0 in the current view) that has an entry gets
value - i × 10^-(digits in the view's item count + 1). The step is a JavaScript float multiplication, float noise included (0.01 × 57gives0.5700000000000001). Eagle reads the direction flag here before it is set, so the values always step down. - Side effects: dragged items save as an ordinary edit (
lastModifiedbumped). A folder that wasn’t onMANUALswitches toMANUALwithsortIncrease: true. - Dropping onto one of the dragged items, or into a view with no other items, throws in Eagle, so nothing changes.
Falcon: Item::manual_order(folder) reads the value as a number, Item::manual_sort_key(folder) gives the string the sort compares, and Item::set_manual_order writes one. search::sort_manual(items, folder, increase) is the MANUAL sort. Library::move_items_manually(folder, ids, before) moves items as a block before another item (None for the end), using the same decimal arithmetic (manual::Dec) and collision handling. It works out the order over all untrashed items in the folder, where Eagle uses the items currently shown (inferred to be the same when no filter is on). Moving items that aren’t in the folder, or placing them before one of themselves, is refused.
mtime.json (observed)
An object that maps item IDs to ms times, ending with "all". In the fixture it lists only 32 of the 91 items, yet all is 91. So all is the library’s total item count, not the number of entries.
Eagle’s Empty Cache and Reload reads the library again and rewrites mtime.json from what it read. Falcon’s Library::rebuild_mtime() does the same from the items in memory: one entry per item that has a lastModified, in id order, then all set to the item count.
Encoding (observed)
Eagle writes compact JSON with no trailing newline and keeps keys in insertion order.
How falcon does it
falcon_library::Library::open checks the library’s version (see Newer libraries and outside changes) and loads every JSON file into typed structs. Library::save_to writes them back out.
- Lossless: unknown keys are kept in an
extramap on each struct. - Number format:
ratiostays a rawserde_json::Number, so45isn’t rewritten as45.0. - Key order: each item remembers whether
widthcame beforeheight.
Together these make every JSON file in the test fixture re-serialize byte-for-byte, which the round_trips_byte_identically test checks.
Newer libraries and outside changes
Version check. metadata.json carries the applicationVersion of the Eagle that last saved it. Falcon reads and writes the format of Eagle 4.0.0 (library::APP_VERSION), and Library::open refuses a library whose applicationVersion is newer, with Error::NewerLibrary, as Eagle refuses a library from a newer release and asks for an update. Versions are ordered the way Eagle orders them (compare_versions): an optional leading v, then up to four dot-separated numbers compared as numbers (10.0.0 is newer than 4.0.0, and a missing number counts as 0, so 4 equals 4.0.0). A -prerelease part sorts before the release itself (4.0.0-beta.2 is older than 4.0.0, 5.0.0-beta is still newer than 4.0.0), its dot-separated parts compare as numbers when both are numbers and as text otherwise, and a shorter list sorts first. A +build suffix is ignored. A version that can’t be read is not refused.
Write guard. Library::write_metadata_file is the one place metadata.json is written while a library is open. Before writing, it reads the file on disk and refuses, writing nothing, when
- the disk copy’s
applicationVersionis newer than 4.0.0 (Error::NewerLibrary), or - the disk copy’s
modificationTimeis later than the one falcon last read or wrote (Error::SyncConflict). Another app or a sync tool saved the file in the meantime, and writing would lose its changes.
A disk copy that can’t be read or parsed is overwritten. Falcon’s own writes set the remembered time, so they never conflict with themselves. A refused write sets a flag that Library::take_conflict() returns once, and the app uses it to reload the library from disk (see Several libraries). In batched mode the refusal comes at the flush, and the pending set is dropped rather than retried. Every caller of the library trips the guard, the HTTP API included.
Picking up outside changes. Eagle polls metadata.json and mtime.json every 4 seconds with fs.watchFile, and falcon polls the same two files at the same interval (crates/falcon-library/src/sync.rs; the app’s polling thread is crates/falcon-app/src/watch.rs). The thread compares file times and, when one moved, reads and parses that file off the UI thread. A file caught half written doesn’t parse and is read again on the next poll. An empty mtime.json is ignored, as in Eagle, because it is being rewritten. Applying a change is quick and happens with the library locked, and it never writes, since the changes are already on disk.
metadata.jsonchanged:Library::take_external_metadatatakes it only when itsmodificationTimeis later than the one in memory. The folder tree, smart folders, quick access and tag groups it holds replace the ones in memory, including edits still waiting in batched mode, and its time becomes the base later writes are checked against.mtime.jsonchanged:Library::sync_itemscompares it with the stamps in memory. An id falcon doesn’t hold is a new item and is read from itsmetadata.json. A larger stamp is an edited item, read again. When theallcount differs from the number of items held,images/is listed to drop items whose folder is gone and to add folders the index missed. An item that can’t be read yet, because a sync is still copying it, waits for the next poll. A pending write of an item that was replaced is dropped, since the disk copy is newer.- Falcon’s own writes come back through the same path and change nothing, since their stamps are the ones in memory.
- A vanished library folder is reported once, and
Library::discard_pending_writes()drops everything batched mode was holding, so nothing is written to where the library used to be.
Backups
Eagle keeps snapshots of the library metadata.json in backup/. Only that one file is backed up; item metadata.json files, mtime.json and tags.json are not.
| Behaviour | Status | Detail |
|---|---|---|
| File name | observed | backup-YYYY-MM-DD HH.mm.ss.SSS.json in local time, with dots between the time fields, e.g. backup-2026-10-26 02.15.25.310.json (fixture and Eagle’s code) |
| Contents | observed | A byte copy of metadata.json |
| When | observed | After Eagle rewrites mtime.json because something changed (that rewrite is debounced by 1 s after the last change), and on a 30-minute timer while it runs. Not on every metadata.json write. There is no preference for it |
| Damaged source | observed | No backup when metadata.json doesn’t parse or has no truthy applicationVersion |
| Unchanged check | observed | Skipped when the newest backup has the same size as metadata.json (the content isn’t compared) |
| Which files count | observed | Any name containing backup-; sorted by name, newest first |
| Rotation | observed | At most 100. Eagle trims the list it read before copying to its newest 99, so with the new copy 100 remain. Trimming also runs when the copy was skipped |
| Second copy | observed | Eagle also copies metadata.json to eagle-backup/<hash of library path>.json in its app-data folder, replacing the file whenever it writes a new backup into an existing backup/ list (not for the very first one) |
| Recovery | observed | When metadata.json exists but can’t be read or parsed (or has no applicationVersion), Eagle walks the backups newest first, skips empty ones and ones that don’t parse or lack a truthy modificationTime, keeps the damaged file as backup/metadata.json.damaged-<ms>, and copies the first good backup over metadata.json. A missing metadata.json is reported as a missing library instead |
How falcon does it
falcon_library::backup follows the table. Library::backup_metadata() takes a backup now (returning None when skipped), Library::backups() lists them newest first, and Library::restore_metadata_from_backup(path) restores a chosen one. Library::open_or_restore(path) opens a library and, when metadata.json can’t be read or parsed, restores the newest usable backup first, as Eagle does on launch. falcon also requires the backup to load as a LibraryMetadata, slightly stricter than Eagle’s modificationTime check (inferred to be harmless: a backup without the standard keys would break Eagle too).
Library::save_metadata and Library::flush do not take backups, because Eagle doesn’t back up on each write. FlushReport::wants_backup() says when a flush wrote mtime.json or metadata.json. Callers take the backup with backup_metadata(), or backup::backup_library(root), which needs only the library folder and so can run on another thread while the library is in use (a metadata.json caught mid-write doesn’t parse and is skipped). falcon backup <library> takes one by hand; the app schedules them (see Saving).
The second copy lives outside the library. backup::app_data_backup_name(root) names it <hash>.json, where <hash> is the 32-bit FNV-1a hash of the library path (no trailing slash) over its UTF-16 code units, as 8 lowercase hex digits, the same name Eagle gives it. The app keeps these in eagle-backup/ inside its own app data folder, ~/Library/Application Support/Falcon/eagle-backup/ on macOS, and backup::backup_library_with(root, app_backups) replaces the library’s copy with every new backup. A failure there is ignored, as in Eagle, and the copy is written through a temporary file and a rename so a reader never sees half a file. Nothing is written for a library whose folder is gone, so a backup never recreates a moved or deleted library.
Merging libraries
Eagle’s Library ▸ Import Library merges another library into the open one. Falcon’s falcon_library::library_merge follows it:
- Refusals: the library that is open is refused, a folder without
metadata.json(or with one that doesn’t parse) is not a library, and a library from a newer Eagle is refused as on open. - Folders: a top-level folder whose id the open library already has is not added again, and its subfolders go through the same test inside the existing one. New folders come in with their whole subtree.
- Smart folders and tag groups: those whose id is already present are skipped and the rest are appended.
- Items: every
images/<id>.infofolder the open library lacks is copied verbatim, and ones it has are left alone. The items keep their folder ids, which the merged tree now contains.
Falcon differs from Eagle in two small ways. It copies only *.info folders, not stray files in images/. And a new folder’s descendants whose ids already exist elsewhere in the open library are merged into those instead of being added a second time, so the tree never holds one id twice. Each item folder is copied under a temporary name and renamed into place, so an interrupted merge never leaves half an item.
The work is split so the app stays responsive. merge::plan(&lib, source) reads the other library and reports what would come in (MergePlan::counts()). merge::copy_items needs no lock on the open library, runs on a background thread, reports progress, can be cancelled and returns the folders copied, with the reason for any that failed. Library::finish_merge then merges the folder tree, saves metadata.json and registers the copied items in memory and in mtime.json, returning a MergeReport of folders, smart folders, tag groups and items added.
Adding to another library
Eagle’s Add to Other Library copies items, a folder, a smart folder or a tag group of the open library into another one. Falcon’s falcon_library::add_to_library follows it:
- Items: each
images/<id>.infofolder is copied whole. One whose id the other library already has is left alone. The copy’smetadata.jsonloses itsfolders, and itsmodificationTimebecomes the time of the copy plus the item’s position in the selection, so the items show up as newly added, in order. - A folder: the folder and its subfolders, ids kept, go to the top of the other library’s tree, unless it already has the folder’s id. Subfolders whose ids it has are not added twice. The items in the folder and its subfolders (not those in the Trash) are copied as above but keep their
folders, and in a folder sorted by hand they also keep theirmodificationTime. - A smart folder or a tag group: only the definition is added, at the top of its list, under new ids (a smart folder’s subfolders too).
- The other library’s
tags.jsonis left alone, as Eagle leaves it.
Falcon differs in three small ways. An item copied with a folder keeps only the folder ids the other library has once the tree is merged, so no item points at a missing folder. A new folder’s descendants whose ids exist elsewhere in the other library are merged into those, as when merging libraries. And each item folder is copied under a temporary name, its metadata.json rewritten there, and then renamed into place, so the other library never sees half an item.
add_to_library::check_target refuses the open library itself, a folder that is not a library and one from a newer Eagle. copy_items needs no lock, reports progress and can be cancelled. Library::finish_add, called on the other library, adds the definition (saving metadata.json only then) and registers the copied items in memory and in mtime.json.
Inboard backups
Eagle’s File ▸ Import ▸ Inboard reads a backup folder made by the Inboard app, and falcon_library::inboard reads it the same way. The folder holds InboardBackup.json, with folders (each with a title) and items, plus the files in images/ and screenshots/.
- Folders: one top-level folder is created per Inboard folder, all of them and in order. When two share a title, items go to the later one (
inboard::folder_map). - Items: only items whose
typeis"image"are imported. The file is the last component of the item’spath, looked up inscreenshots/when that path runs through a/screenshots/folder and inimages/otherwise. An image whose file isn’t in the backup is counted as missing (InboardBackup::missing) and skipped. - What an item keeps: Inboard’s
titleas its name, itstags, itsurl, its trashed state (isTrashedother than 0, ortrue) and itsdateas the date added. A numericdateis read as milliseconds, and a string as RFC 3339 orYYYY-MM-DD HH:MM:SS ±ZZZZ. - Folders of an item: its
foldersare Inboard folder titles, mapped to the new folder ids; titles that match no folder are dropped.
inboard::is_backup(dir) says whether a folder has the index, and inboard::read(dir) returns the folders and importable items. The import itself goes through the ordinary import path, so duplicate handling and thumbnails are the same as for other files.
Pixave libraries
Pixave, LittleHJ’s discontinued macOS image manager, keeps each library in a package whose name ends in .pxvlibrary. Eagle 4.0.0 has no Pixave importer: its menus have no Pixave entry and Import Folders silently skips a .pxvlibrary folder. Falcon does the same (folder_import::is_pixave_library); it doesn’t import Pixave libraries or items.
Undo
| Behaviour | Status | Detail |
|---|---|---|
| Eagle’s undo | observed | Not a history. Some actions (removing items from a folder, deleting a folder or smart folder, renaming a folder, deleting a comment) show a notice with an undo link that holds one restore callback for the notice’s duration plus 5 seconds; ⌘Z runs the same callback. Each new notice replaces it |
| How Eagle restores | observed | The callback puts the old values back in memory and saves them like any edit, so restored items go through the normal item save, which stamps a new lastModified |
| Permanent deletes | observed | Emptying the trash and deleting permanently remove the .info folders and offer no undo |
How falcon does it
falcon keeps a real history. Library::begin_transaction(label) starts recording and end_transaction() returns a undo::Transaction, or None when nothing changed. While a transaction is open, the write paths record what they replace:
register_item(every item save and import) records the item’s first pre-edit copy, or its id as created when it didn’t exist.save_metadata,save_tagsandsave_filtersrecord the value as last saved, once per transaction. The library keeps a copy of the last savedmetadata,tagsandsaved_filtersfor this, so edits that change the fields before saving still record the old value.forget_item(permanent delete, empty trash) marks the transactionirreversible. The files are gone, so the app clears its undo history when such a transaction lands, as Eagle offers no undo there either.
Library::revert(tx) restores the recorded state in a new transaction and returns it, so reverting that redoes the edit. Items are saved through update_item, so a rename moves the original and thumbnail back, and lastModified moves forward, which matches Eagle’s restore (observed above). Items created in the transaction (imports) go to the trash, with their files kept. Library metadata gets a new modificationTime. Recording never changes what is written, so the fixture round trip is unaffected.
Write batching
| Behaviour | Status | Detail |
|---|---|---|
| Item saves | observed | Queued per item id in a worker queue (8 at a time). A later change to an item still waiting in the queue replaces the queued copy, so each item is written once. lastModified is stamped when the file is written |
mtime.json | observed | 2 s after the item queue drains, then after another 1 s debounce, Eagle rewrites mtime.json from its in-memory map of id to lastModified. It drops ids no longer in the library, adds new ones and sets all to the item count. It is written to a temporary file and moved into place |
Library metadata.json | observed | Folder, smart folder, quick access and tag group changes are debounced by 300 ms and written by a one-at-a-time queue |
| Backup | observed | Taken right after a changed mtime.json is written (see Backups) |
How falcon does it
Library::set_write_mode(WriteMode::Batched) makes the item, mtime.json, metadata.json, tags.json and saved-filters.json writers mark what is dirty instead of writing. Library::flush() then writes each dirty item once, mtime.json once, and each library-wide file once, and returns a FlushReport. Because every writer serialises the whole in-memory value, the bytes after a flush are the ones immediate mode would have written last. When mtime.json or metadata.json was written, FlushReport::wants_backup() is true, and the app schedules a backup, as Eagle backs up after rewriting mtime.json (falcon also counts a metadata write, inferred harmless: the backup is skipped when the size is unchanged). The flush itself takes no backup. File renames and permanent deletes still happen at once. An item deleted before the flush is skipped. If a write fails, the pending set is kept for the next flush. Dropping a batched library with pending writes flushes it, ignoring errors. WriteMode::Immediate is the default, used by the CLI, the server and the tests. Switching back to it flushes first.
falcon writes JSON files in place rather than through a temporary file, as before.
Export (.eaglepack)
An .eaglepack is how Eagle moves items and folders between libraries.
Refs.eaglepack a zip archive, entries stored without compression
├── pack.json {"images": [...], "folder": {...}}
└── <ID>.info/ each item's whole info folder, as in images/
├── metadata.json
├── <name>.<ext>
└── <name>_thumbnail.png
| Behaviour | Status | Detail |
|---|---|---|
| Container | observed | Zip, written by Eagle’s archiver with store: true (no compression). The 7-Zip binaries Eagle ships are not used for packs |
| Entry order | observed | pack.json first, then each item’s <ID>.info folder in images order |
pack.json keys | observed | images first; folder only for a folder export |
images | observed | Item records (the item metadata.json contents, minus Eagle’s in-memory fields), sorted by modificationTime, newest first |
| Exporting items | observed | Every record gets "folders": []. Trashed items may be exported; their record loses isDeleted |
| Exporting a folder | observed | Untrashed items in the folder or any subfolder; their folders keep the original ids, including folders outside the export. folder is a copy of the subtree with id, name, description, children, modificationTime, tags, then extendTags, icon, iconColor, pinyin when set, password, passwordTips, coverId when set, and orderBy with sortIncrease when a custom sort is set |
| Duplicate records | inferred | Eagle’s export means to skip items listed twice, but the map it checks is never filled, so an item in a folder and its subfolder may be listed twice. falcon lists each item once |
| Names | observed | Eagle skips items whose name contains characters the file system rejects. falcon’s names are already sanitized at import |
| Verification | observed | After writing, Eagle checks the zip signature (PK\x03\x04 and its variants) and that the file is at least 80% of the bytes written; otherwise it deletes the file. falcon writes to a temporary file, reads it back (signature, entry count, pack.json parses) and only then moves it into place |
| Import: extract | observed | Unzipped (with unzip -o on macOS, which keeps file times) into a temporary folder; fails without pack.json |
| Import: order | observed | images is reversed (oldest first). For a folder pack, items are sorted by order[<old folder id>], falling back to modificationTime |
| Import: ids | observed | Every item and folder gets a new id; nothing keeps its old one. Folders are marked isExpand: true and coverId follows the item’s new id |
| Import: folders | observed | A folder pack’s tree is added to the library and item folders are mapped to the new ids. A pack of items dropped on a folder goes into that folder; otherwise items keep their folders |
| Import: records | observed | Each item’s metadata.json is rewritten from its pack.json record with the new id and modificationTime = now + index; lastModified is kept |
| Import: duplicates | observed | Eagle asks whether to skip items that match existing ones by its duplicate key: for most images size + width + height + ext, for videos size + duration; SVG, TIFF and some other kinds never match |
| Smart folders | observed | Exporting a smart folder writes a folder pack of its matching untrashed items. folder is the smart folder copied with the folder keys above under a new id, with no children (subfolders are not carried), and every item’s folders is set to that new id, so the items import into one plain folder. Smart folder rules are never carried |
How falcon does it
The app writes packs on a background thread, so the work is split in two: plan_pack(&lib, source) builds pack.json and the list of item folders into a PackPlan that holds no reference to the library, and write_pack(&plan, dest, on_item) writes it, reporting each item folder added. falcon_library::pack::export_pack(&lib, PackSource::Items(ids) | PackSource::Folder(id) | PackSource::Smart(id), dest) does both and writes a pack following the table, using the zip crate with stored entries and each file’s time and Unix mode. preview_pack(&lib, pack) reads a pack without extracting it, as Eagle does before it asks: it opens the zip, adds up the uncompressed size of every entry, parses pack.json and counts how many of its items look like untrashed items already in the library, using the same duplicate key as skip_duplicates. It returns a PackPreview of items, duplicates and bytes, and fails with Error::Pack when the file isn’t a zip or pack.json is missing or has no images list. import_pack(&mut lib, pack, &PackImportOptions { folder, skip_duplicates }) imports one and returns the new item ids. Item files are moved in as they are (thumbnail included), not re-imported, as Eagle does. Differences from Eagle, all inferred to be harmless:
- falcon drops item
foldersids that don’t exist after import, where Eagle would writenullor keep a dangling id. - An item whose
<ID>.infofolder is missing from the pack is skipped; Eagle still lists it, with no files. - A record without
isDeleted(an exported trashed item) is written back with"isDeleted": false, since falcon’s item model requires the key. - The duplicate key covers images and videos only; audio and font keys are not implemented.
The CLI has falcon export <library> <out.eaglepack> [--folder ID | --smart ID | ITEM...] and falcon import-pack <library> <pack> [--folder ID] [--skip-duplicates].
Item and folder IDs
IDs are 13 characters of uppercase base 36, for example MVP1BZ832CIJ1.
The timestamp prefix (observed)
The first 8 characters are the creation time in milliseconds, written in base 36:
| ID | First 8 characters | Decoded | Item’s modificationTime |
|---|---|---|---|
MVP1BZ832CIJ1 | MVP1BZ83 | 1793005755411 | 1793005755411 |
MVP1BZ8496KO7 | MVP1BZ84 | 1793005755412 | 1793005755412 |
MVP1JTFJI1HWU | MVP1JTFJ | 1793006121151 | 1793006121151 |
Because of this prefix, sorting IDs as strings also sorts items by creation time, as long as the prefix stays 8 characters. That holds until the year 2059.
The suffix
The last 5 characters tell apart items created in the same millisecond. Whether they are random or a counter is still being checked against app.asar.
Import pipeline
What Eagle does
Every claim is from Eagle’s plain-JS background window code (processImage in background.js). The Electron main process is V8 bytecode and was not read.
- Stat the source (observed).
sizeis the byte length.btimeandmtimeare the file’s birth and modification times as integer milliseconds, read before the copy. - Reject (observed). A file with no extension is refused (“File format is not recognized”), and so are unfinished browser downloads (
.crdownload,.part,.download), whichgetExttreats as having none. Any other extension, known or not, goes ahead. So is an empty file refused, unless its extension istxt. The refusal goes to the failed-imports list; there is no dialog. - Name (observed). The extension is removed, the name is sanitised and trimmed, shortened to fit the file system, normalised to NFC, and replaced by
_if empty. The upload step strips/and%first. - Directory (observed).
images/<ID>.info/is created. The original is<name>.<ext>and the thumbnail<name>_thumbnail.png. - Item record (observed).
isDeletedis false,urlandannotationare empty,tagsandfolderscome from the caller. - Copy (observed).
copyFile, thenutimesso the copy keeps the source’s mtime. A failed copy removes the.infofolder and reports an error. - Thumbnail (observed). Sets
width,height,noThumbnailandremoveThumbnail. See Thumbnails. If it throws, the item is still added, without a thumbnail. - Palette (observed). Computed from the thumbnail, or from the original when
noThumbnail && !removeThumbnail. WhenremoveThumbnailis set, the thumbnail is deleted once the palette task finishes. - Metadata write (observed). Each write sets
lastModifiedto the current time and writes the item’smetadata.json.
Format metadata
Eagle’s thumbnail modules (app/js/thumbs/) also add keys for some formats. New keys go after the ones already set, in the order below. In Eagle they land before lastModified and palettes. Falcon keeps unknown keys after palettes, so it writes them at the end. Eagle’s reader doesn’t depend on key order.
RAW photos (observed). These are 3fr, arw, orf, mrw, nef, nrw, raw, cr2, crw, dng, erf, pef, raf, rw2, sr2, srw, x3f and cr3. Eagle runs dcraw’s identify on the file and stores rawMetas:
| Key | Value | Example |
|---|---|---|
timestamp | date taken, ms; dcraw prints local time and Eagle parses it back as local | 1541495719000 |
camera | make and model as dcraw names them | "Pentax K-1 Mark II" |
isoSpeed | integer | 200 |
focalLength | string | "200.0 mm" |
shutter | string, 1/N.N sec under a second, else N.N sec | "1/320.0 sec" |
aperture | string | "f/3.2" |
width, height | dcraw’s full sensor size, also copied to the item | 7392 |
- Missing values are left out. An empty object is not stored.
- JPEG, HEIC and TIFF get no
rawMetas. Eagle reads their EXIF only for orientation. - Smart-folder rules parse the strings back: the first number of
apertureandfocalLength, and the second number ofshutter. So"2.0 sec"never matches a shutter rule.
Video (observed). resolutionWidth and resolutionHeight (copies of width and height), then duration. All come from Chromium’s <video> element. duration is in seconds with a fraction.
Audio (observed). mp3, wav, flac, ogg, aac and m4a. width 280 and height 140 (the waveform thumbnail, see Thumbnails), then duration from an <audio> element. Then bpm (observed in js/thumbs/audio.js): after drawing the waveform, Eagle mixes the decoded audio to mono and runs a bundled beat tracker (music-tempo) over it. For files over 3 minutes it analyses only the start: the first 3 minutes, or the first 2 past 10 minutes, or the first 1 past an hour. The tracker searches beat intervals of 0.3 to 1 second (60 to 200 BPM) and reports the tempo as a string with three decimals. Eagle stores it with parseFloat, so it is a JSON number such as 119.985, or 120 when whole. When the tracker finds no tempo, nothing is stored (inferred for silence, where the tracker has no beats to follow). Refresh Thumbnail recomputes it.
Fonts (observed). For ttf, otf, ttc, woff and woff2 (Eagle’s FONT_FORMATS; an .otc is a plain item), Eagle parses the file with opentype.js and stores fontMetas:
- First, one key per
nametable entry, in table order:copyright,fontFamily,fontSubfamily,uniqueID,fullName,version,postScriptName,trademark,manufacturer,designer,description,manufacturerURL,designerURL,license,licenseURL,preferredFamily,preferredSubfamily, and others. Each is a map from language code (en,zh,zh-TW,ja, …) to text. A Windows record replaces the Macintosh text for the same language but keeps its position. - Then
numGlyphs. - Then
support, which holdszh_CN,jp,zh_TWandkrset to true when the font has glyphs for a few test characters of that script. - Then
weight. It is the first of Thin, Hairline, ExtraLight, UltraLight, Light, Regular, Normal (written Regular), Medium, DemiBold, SemiBold, Bold, ExtraBold, UltraBold, Black, ExtraBlack and Heavy found in the lower-cased PostScript name. If none is found, the subfamily is used when it is one of those words, and otherwise Regular. “ Italic“ is appended when the PostScript name contains “italic”. Because “bold” is tested before “extrabold”, an ExtraBold font is labelled Bold. - A font with no names gets a random
postScriptNameandfullName. One with no PostScript name uses itsuniqueID. - With the “auto tag” font preference, which is on by default, the weight is also pushed onto
tags. - Fonts opentype.js can’t parse (collections, woff2) go through a second parser, Typr, which writes a different fixed set of keys, every one with only an
envalue. - WOFF 2 in Eagle 4.0 (observed in code, result inferred): woff2 is routed to the font handler, but neither the bundled opentype.js 0.11 nor its Typr build has a WOFF 2 reader (neither looks for the
wOF2signature). Both parses are inferred to fail, so a woff2 item gets nofontMetasand no weight tag.
IPTC keywords (observed)
- The preference
general.IPTCis off by default. When it is on, Eagle reads keywords from files whose extension is exactlyjpg. A.jpegfile is not read. - It looks only at the first APP13 segment, in the “Photoshop 3.0” resource 0x0404, and takes IIM dataset 25 (keywords) from it. XMP is never read.
- A keyword field over 200 characters is skipped. The rest are split on
;. - The parts are appended to the caller’s tags, and the whole list is de-duplicated (first occurrence wins).
- This happens before the copy, so the keyword tags come before the font weight tag.
Duplicates (observed)
Eagle stores no content hash in item JSON and never hashes file bytes. Its duplicate check (getHashID) builds a key from metadata alone:
| Kind | Key built from |
|---|---|
| Video | size and duration |
| Audio | size, duration, extension, and bpm when known |
| size, width and height | |
| Fonts | size, fontMetas.uniqueID, postScriptName and version |
| URL bookmarks | the video id or the URL |
| Images | the first palette colour rounded up to tens, the second and third colours, then size, width, height and extension |
- svg, tif, tiff and trashed items are skipped.
- Images without a palette fall back to size, width, height and extension.
The key is used when files are imported (seen in file-uploaded, isDuplicateImage and the duplicateModal directive):
- Each imported item whose key matches an item not in the Trash has the first 4,096,000 bytes of both files compared. When they are equal the new item, already written to the library, is held back from the list. The key alone (bmp, tga, txt and formats Eagle can’t preview get an empty key) never counts.
- When the upload queue has finished, the Duplicate File Alert opens. It shows the existing item (“Existing”, with its first folder or Uncategorized) beside the new one, two radio choices, Use existing file (the default) and Keep both, an Apply all (N) box when more than one file is held, and an Import button. The close button or Esc is Don’t Import. ⌘↩ and ⌘Esc answer for every file. A click outside does nothing.
- Use existing file: the new item is deleted for good. Each of its folders the existing item isn’t in is added to it, with that folder’s auto-tags; folders and tags are de-duplicated, and the existing item’s
modificationTimebecomes the new item’s, so it sorts as just added. The new item’s own tags and notes are not carried over. - Keep both: the new item stays.
- Don’t Import: the new item is deleted for good.
- The preference
notification.notification.when.repeatImage(Notify me when importing duplicates, under Notification) is on by default and also needs the notification master switch. When either is off, nothing is asked and duplicates are imported as new items.
Folder auto-tags (seen)
A folder’s tags key holds its auto-tags. Eagle’s code calls the list it applies extendTags; it is worked out at run time (getExtendTags) and deleted before metadata.json is written, so it is not a setting.
- The list is the folder’s own tags, then its parent’s, then the grandparent’s and so on to the top level. Only the first copy of each tag is kept, and the result is reversed. A folder with tags
[d, e]under[c, b]under[a, b]applies[a, b, c, e, d]. - Importing or pasting files into a folder in Eagle’s window appends the list to the item’s tags and removes repeats. Importing into several folders (the add-from-URLs path) joins each folder’s list in turn.
- Adding items to a folder (the inspector’s folder picker, Add to Folder) appends the tags an item lacks, but only when the item was not already in that folder.
- Dropping items onto a folder appends the missing tags to every dropped item, even one already in the folder.
- Eagle’s API
addFromPathandaddFromPathsdon’t apply auto-tags;addFromURL(s)do. - There is no preference for any of this.
Timestamps
btimeandmtimeare the file’s own times (observed).modificationTimeis the import time. A batch gives each itemDate.now() + index, and a repeated value is incremented, so values within a batch are unique (observed). Later saves do not change it (observed).lastModifiedis the time of the latestmetadata.jsonwrite, so it is a little later thanmodificationTime(observed).
mtime.json
- Maps each item id to its
lastModified, followed byall, the number of items loaded fromimages/(observed). Trashed items are probably included (inferred; not checked against a library with trash). - It is rewritten about one to two seconds after the write queues drain, not once per item (observed).
- Other Eagle instances watch it to notice new or edited items (inferred from the reader code).
How falcon does it
Library::import_file(path, &ImportOptions) runs the same steps in the same order and returns the new id. ImportOptions holds folders, tags, palette (default true), annotation and url.
-
Source times.
btimefalls back to the mtime when the file system has no birth time. Eagle’s behaviour in that case is unknown. -
Name. Falcon removes control characters and
/ ? < > \ : * | " %, trims spaces and trailing dots, normalises to NFC, and cuts the name so that<name>_thumbnail.pngfits in 255 bytes. The exact length Eagle allows is not known. -
Copy.
fs::copy, then the source’s access and modification times are set on the copy. There is no cut mode and no fallback copy. -
Unsupported formats. As in Eagle,
import::is_recognizedrefuses only files with no extension and unfinished browser downloads (.crdownload,.part,.download), withFailKind::Unrecognized, so they appear in the failed-imports list. Every other file imports. Formats falcon can’t draw itself go to QuickLook, and a file nothing can draw is imported withwidthandheightof 0, no thumbnail and no palette. Eagle marks such an itemnoPreview: trueand leaves outwidth,heightandpalettes(see Library format); falcon does not write those differences yet.Eagle’s “The file format is not supported” dialog (
Dialog.NotSupport.Format.*) appears only when something dropped from a browser is neither a file nor a link nor image data (onDropContainer). Two other uses of it, for unknown file types in drops and folder imports, are commented out in Eagle 4.0.0, and those files are imported. falcon only receives file drops, so it has no such dialog. -
Failures. If decoding or encoding fails, the item is kept with no thumbnail and no flags, as Eagle does for a thrown error. If the copy fails, the
.infofolder is removed and the error is returned. An unknown folder id is rejected before anything is written. -
Link files. A dropped
.url(Windows shortcut) file takes its address as the item’surl(weblink::read), unlessImportOptions::urlis set. Files over 1 MiB or without an address leaveurlempty. A.weblocis not in Eagle’s list, so it imports as a plain file with nourl. -
Palette. Taken from the decoded thumbnail, so it sees the same WebP artefacts Eagle’s analyser sees.
palette: falseskips it. -
Timestamps.
modificationTimeis the later of the current time and the previous import’s value plus 1 ms, tracked on theLibrary, so it is strictly increasing across calls.lastModifiedis read after the palette is done. -
mtime.json. Updated and written after every import, not debounced. The entry is set to
lastModified, andallbecomes the count of loaded items. Existing entries that are missing from the file are left missing. -
Folder auto-tags. With
ImportOptions::folder_auto_tagson,commit_importappends the auto-tags of the item’s folders (autotag::auto_tags_for) to its tags, skipping tags it already has, in the same write. It is off by default, as for Eagle’saddFromPath; the app turns it on for every import (drops, ⌘V, the collect window). The list is computed from the folders’tagsbyautotag::folder_auto_tags, which follows Eagle’s order exactly; a cachedextendTagskey in a file is ignored. -
Not done. Library
metadata.jsonis not touched, no backup is taken, and stars and concurrency are not implemented.
Format metadata in falcon
rawMetas. Falcon reads EXIF with thekamadak-exifcrate instead of running dcraw, and prints the values the way dcraw does:camerais the Make tag shortened to a known maker name (NIKON CORPORATIONbecomesNikon), followed by the Model with a repeated maker word removed. This is an approximation of dcraw’s table (inferred).timestampis DateTimeOriginal, or DateTime, read as local time.widthandheightare not written. EXIF doesn’t hold dcraw’s full sensor size.- Readable RAW files: the TIFF-based ones (dng, nef, nrw, cr2, arw, srw, pef, 3fr, erf), plus four with their own containers. crw, mrw and x3f give nothing.
- orf, rw2: TIFF with another magic number in place of 42 (
IIRO,IIRSorMMORfor Olympus,IIU\0for Panasonic). Falcon reads them as TIFF. The camera tags sit where they do in any TIFF. - raf: a
FUJIFILMCCD-RAWheader with the embedded JPEG preview’s offset and length (big-endian, at bytes 84 and 88). That JPEG’s EXIF holds the camera facts. - cr3: an ISO media file (
ftypbrandcrx). Insidemoov, Canon’suuidbox (85c0b687-820f-11e0-8111-f4ce462b6a48) holdsCMT1, a TIFF block with the main IFD (Make, Model, DateTime), andCMT2, a TIFF block whose first IFD holds the EXIF tags (exposure, f-number, ISO, DateTimeOriginal, focal length). Falcon reads both. - All four produce the same
rawMetaskeys and formats as the TIFF-based files. - Eagle’s dcraw build is inferred to read orf, rw2 and raf, which classic dcraw supports. Classic dcraw predates CR3, so Eagle is inferred to store no
rawMetasfor cr3 files, where falcon does.
- orf, rw2: TIFF with another magic number in place of 42 (
ImportOptions::camera_metadata_all_photos(off by default) also storesrawMetasfor jpg, jpeg, tif, tiff, heic, heif, png and webp. Eagle never does this. It lets the camera filter work on ordinary photos.
duration.- Audio is read with
falcon_library::audio: the length the container gives, after trimming encoder priming (MP3 gapless headers, the MP4 edit list oriTunSMPB, the Opus pre-skip), or by adding up packet durations when the header has no count. Raw AAC is counted frame by frame. A file that doesn’t decode gets noduration. Only Eagle’s six audio formats get one. - mp4, mov, m4v, 3gp and f4v are read from the
mvhdbox (duration ÷ timescale). - Other video formats are read through FFmpeg when it is installed (see Video playback), then fall back to Spotlight (
mdls), which knows nothing about files in temporary folders. - Values can differ slightly from a browser’s, for example around MP3 encoder padding.
- Audio is read with
- Video sizes.
resolutionWidthandresolutionHeightcopy the item’swidthandheight. bpm.tempo::bpm(path, ext)estimates the tempo with falcon’s own method rather than Eagle’s beat tracker, over the same opening stretch (tempo::analysed_seconds):- The audio is decoded with
falcon_library::audioand mixed to mono. A loudness-rise envelope is built at about 100 frames a second: the log RMS over two hops, its positive differences, lightly smoothed, with the mean removed. - The envelope’s autocorrelation is scored for every tempo from 60 to 200 BPM in 0.01 steps. Each score averages the autocorrelation at 1 to 4 beat periods, read between lags by cubic interpolation, and is weighted by a gentle preference for tempos near 120 (one octave wide). The best score wins, rounded to three decimals, and is written right after
duration, whole values without a fraction. - Nothing is written when the file is silent, too short (under about two seconds), has no clear rises (a steady tone), or no beat stands out (hiss).
- Checked on generated clicks at 60, 75, 90, 120, 140 and 174 BPM, and on a drum loop over a drone, all within 1 BPM. Clicks at 200 read as 100, an octave down, because the preference for 120 breaks the tie. Eagle’s tracker can also land on a different octave, and values on real music will differ from Eagle’s.
- Refresh Thumbnail recomputes
bpmfor audio items, as Eagle’s refresh does (generated.bpmreplaces the item’s value, seen inapp.bundle.js). An existing key keeps its place in the record; when no beat is found the key is removed, as Eagle’sundefineddrops it on write. Refreshing an item Eagle analysed replaces Eagle’s tempo with falcon’s estimate, which can differ.
- The audio is decoded with
fontMetas.- Read with
ttf-parser(ttf, otf, the first face of a ttc) after falcon’s own WOFF 1 and WOFF 2 unpackers (fontmeta::sfnt). - WOFF 2 goes further than Eagle: falcon writes the same opentype.js-shaped
fontMetas(and weight tag) as for the plain font. The Brotli stream is decoded withbrotli-decompressor, and the tables are rebuilt into an sfnt. Tables stored with a WOFF 2 transform (the packedglyfandloca, and a transformedhmtx) are left out, since names,maxpandcmapare all metadata needs. WOFF 2 collections are not read. - A TrueType woff2 with packed glyphs therefore has no outlines for the 600×600 specimen. Its thumbnail falls back to QuickLook. CFF-flavoured woff2 and woff2 files with plain
glyfget the specimen. - Language codes cover the common Windows and Mac Roman languages. Records in other languages or Mac encodings are skipped, where opentype.js would decode them.
- Collections get the opentype.js shape, not Typr’s.
- Eagle’s MD5 fallback for fonts with a family name but no English PostScript name is not reproduced.
ImportOptions::font_weight_tag(default true, as in Eagle) adds the weight tag. The app sets it from the Preferences switch in the Preview pane.
- Read with
Duplicates in falcon
The duplicates module compares file contents instead of copying Eagle’s metadata key, and writes nothing to the library.
- Exact copies.
find_exact_duplicatesgroups items, trash excluded, whose originals hash to the same BLAKE3 digest. Only files that share their size with another file are hashed. - Near copies.
find_near_duplicates(lib, threshold)takes a 64-bit perceptual hash of each item’s thumbnail, or of the original for small images shown without one. The hash is a 32×32 grey DCT, keeping the 8×8 lowest frequencies, each compared with their median. Items withinthresholdbits are grouped, transitively. About 10 suits resized or recompressed copies. - Cache. The
_cachedvariants take aHashCache, keyed by item id and invalidated when the original’s size or mtime changes.HashCache::saveandloadkeep it as JSON wherever the caller likes. - Results. Groups list item ids in library order.
The duplicate check on import in falcon
import_check holds the library side. Library::duplicate_candidates(size, ext) lists items not in the Trash with the same size and extension (no file is read), and import_check::same_file compares a staged copy with them by BLAKE3 hash of the whole file, which is at least as strict as Eagle’s 4 MB prefix compare. svg, tif, tiff, bmp, tga, txt and files without an extension are not checked. Staged::original gives the staged copy’s path. Library::use_existing_for_import(existing, folders) is Use existing file: the item joins each folder it isn’t in, with the folder’s auto-tags, and its modificationTime becomes the import time. Unlike Eagle, falcon checks before committing, so a duplicate is never written to the library and nothing has to be deleted afterwards. The app’s side (holding duplicates and asking) is described in The desktop app.
Keywords in falcon
ImportOptions::keywords_as_tags is off by default, as in Eagle. When it is on, the keywords module reads:
- jpg and jpeg: the IPTC block in APP13, parsed as proper IIM records (record 2, dataset 25), and the XMP packet in APP1.
- png: the XMP packet in the
iTXtchunkXML:com.adobe.xmp, compressed or not. - tif and tiff: the IPTC (tag 33723) and XMP (tag 700) entries of the first IFD.
IPTC keywords come first, then the XMP dc:subject items. Eagle’s 200-character limit and ; split apply to both. Falcon also drops empty parts, where Eagle would keep an empty tag. Reading .jpeg, XMP, PNG and TIFF goes beyond what Eagle does.
Screenshots
What Eagle does (seen in its shipped source)
A screenshot is captured to a temporary PNG, converted to the format and quality in Preferences ▸ Screenshot (screencapture.format, screencapture.quality), and halved when screencapture.useRetina is off on a scale-2 screen. The item is named “Screenshot - YYYY-MM-DD HH.mm.ss”. It gets the tag “Screenshot” when it has no tags and screencapture.autoTagging.enable is on, and is copied to the clipboard when screencapture.autoWriteClipboard is on. An area or window screenshot opens the collect window first when general.showCaptureCollectModal is on, and the item is saved into the folders chosen there. Otherwise, and always for a full-screen screenshot, it is imported into the library without a folder.
How falcon does it
crates/falcon-app/src/capture.rs makes the file and app/system.rs hands it to the import (see Screen capture in the app chapter).
- Name. “Screenshot - YYYY-MM-DD HH.mm.ss” in local time, passed as
ImportOptions::name. The staged file has the same name, with “ 2“, “ 3“ and so on added if two shots share a second, but the item keeps the name above. - Tag. “Screenshot” is put in
ImportOptions::tagsunless Preferences ▸ Screenshot turns tagging off. falcon adds it through the same tag helper the collect window uses, so it never doubles a tag typed there. (Eagle adds it only when the item has no tags; a screenshot has none at this point, so the result is the same.) - Format and size. The PNG from
screencaptureis converted withexport::convert_bytesto PNG, JPG or WebP at quality 90, 80 or 70. A PNG at full size is only renamed. When “Keep full Retina resolution (@2x)” is off and the screen’s scale is 1.5 or more (Eagle checks for exactly 2), the longer side is halved first. - Where it goes. The library root with no folders, so folder auto-tags have nothing to add. With “Choose where each screenshot is saved” on, an area or window screenshot waits in the collect window, and Save imports it into the folders picked there with the name and tags shown there. A full-screen screenshot never waits.
- Temp files. The file is written to
falcon-pastein the system temp folder. The import deletes it once it has copied it into the library, and Discard in the collect window deletes it. A cancelled capture leaves no file, a failed conversion removes the raw PNG, and a screenshot that arrives after the library closed is deleted. - Same pipeline. It is an ordinary background import: the duplicate check asks what to do when the picture is already in the library, and a failure goes to the failed-imports list. A batch that held only screenshots, imported straight in or saved from the collect window, does not post “Import finished”.
Importing folders
What Eagle does (observed in app.bundle.js)
Where a folder from Finder is dropped decides what happens:
| Drop target | Eagle handler | Result |
|---|---|---|
| The item grid | onDropContainer | Files, and every file inside dropped folders (walked flat), go into the open folder, or into no folder from All and the other views. No folders are created |
| A folder row in the sidebar | onDropFolder | Dropped files go into that folder. Dropped folders are ignored |
| The rest of the sidebar | onDropSidebar | Each dropped folder (sorted by name, numbers by value) goes through uploadFolderToSidebar with no parent, so its tree lands at the top level. Loose files are ignored |
uploadFolderToSidebar walks the folder (getFolderList), then creates an Eagle folder named after the directory, with one child folder per subdirectory, children sorted by name with numbers compared by value. The new folders get no icon, tags or description. Each file is imported into the folder that mirrors its directory, and the new top folder is opened.
File ▸ Import Folders (importFolders) opens a dialog that picks several folders and sends each through uploadFolderToSidebar at the top level. The Import Folders button on an empty folder’s page passes that folder as the parent instead, and the one on the empty All page passes none. A picked .library is opened instead of imported, but only when it is the only folder picked; otherwise it is skipped. Eagle has no Import Files menu item.
Limits (uploadFolderToSidebar, getFolderList, treeDepth): the walked folder counts as level 1 and each level of subfolders adds one. A folder 15 or more levels deep, or with 100,000 or more files, is refused with a notice and nothing is imported. A folder with 2,000 or more files asks for confirmation first (“contains N items”). Eagle’s count leaves out the files directly inside the dropped folder, counting only those in its subfolders (observed in treeDepth). The grid drop has no limits. Only the Import Folders dialog skips Pixave libraries (.pxvlibrary); a sidebar drop would walk one. Inside folders, junk files (.DS_Store, Thumbs.db and the like) are skipped, and Keynote, Pixelmator and MindNode packages (.key, .pxd, .mindnode) count as files rather than folders to walk into.
How falcon does it
folder_import::walk(dir) reads a directory into a Tree (files and subfolders in natural name order). Hidden files (names starting with a dot) and junk (Thumbs.db, Desktop.ini, Icon\r and the like) are skipped, the three package types count as files, and symbolic links to folders are not followed, so a link cycle can’t loop. folder_import::plan(&tree, parent, new_id) maps the tree to NewFolders (parents first, siblings in order) and (file, folder id) pairs, and Library::create_folders adds the folders in one metadata.json write. Folder names are the directory names as the file system gives them, as in Eagle. Tree::stats() counts levels and files and folder_import::verdict applies Eagle’s limits (MAX_DEPTH 15, MAX_FILES 100,000, WARN_FILES 2,000). falcon counts every file, including those directly in the dropped folder, so its count can be a little higher than Eagle’s.
The app follows Eagle’s three drop targets. Because macOS sends no mouse moves while a drag from another app is over the window, the drop position is read from the system mouse location (drop_pos.rs) and compared with the sidebar’s rect and visible folder rows from the last frame. Dropping only files on the empty part of the sidebar shows a hint instead of doing nothing silently. Each folder imported with its tree is checked against the limits on its own: a refused folder gets a notice, and a large one waits for Import or Cancel in a dialog (one dialog per folder, the others still import). Pixave libraries are skipped by every folder import, the grid drop included, with a message. The grid drop walks folders with the same walk as the tree import, so it skips junk and can’t loop through a folder link; it has no size limits, as in Eagle. A .library among the dropped paths is opened wherever it lands. Import Folders… (in the library switcher menu, and on the empty All and folder pages, as in Eagle) runs the same import from an rfd folder dialog that picks several folders; App::import_folders_dialog() is the entry point for a future File menu. Import Files…, next to it, picks files and imports them into the open folder like a grid drop; it is falcon’s own. Both dialogs open once the frame is done, after the library lock is released. A package can’t be imported by falcon yet (it isn’t a regular file), so it is reported as a failed import.
Importing links
File ▸ Import Links… and the New… menu’s Import Links… (file.import.links, no default shortcut) open a box titled Batch Import Links with a text area for one address per line, Cancel and Import. If the clipboard text starts with http, its valid lines are put in the box. Import checks every line against Eagle’s rule (http:// or https://, then at least two characters that start with something other than space, /, $, ., ? or #, and no spaces); if any line fails, the box stays open and lists the lines it refused under “Invalid link format”. Blank lines are skipped and repeated addresses are imported once (link_import.rs, where the rules are tested).
Each address is then fetched, four at a time, with a 10 second limit. The request goes only to that address and carries no user agent, cookies, referrer or other identifying header. If the first bytes of the response are an image (sniff: PNG, JPEG, GIF, WebP, TIFF, BMP, ICO, AVIF/HEIC/HEIF and SVG), or failing that the server answers with an image type, the image is downloaded and imported as a file with the address as its source and the extension of what the bytes are, so a missing, application/octet-stream or wrongly labelled text/html type does not turn an image into a bookmark; for anything else, an unreachable server included, a .url bookmark is imported, as in Eagle, named after the page’s <title> (the first 256 KiB are read) or the host when there is none. Items go into the open folder, with the address as their URL and no tags, through the usual import queue, so progress, the failed-imports list and the duplicate check apply.
The bookmark’s thumbnail
A bookmark made here gets a screenshot of its page as its thumbnail, as in Eagle, once the import has finished (the import itself skips QuickLook for .url, which has no generator and would wait out its 30 second limit for each bookmark); until then, and if no picture can be taken, its card keeps the plain icon. page_shot/ holds the rules and page_shot/webkit.rs the WebKit side; app/page_shots.rs connects them to the app. A queue on the UI thread loads one address at a time in a web view of Eagle’s page size, 1440 × 900, in a borderless window placed off every display. The page has 20 seconds to finish loading, then 2.5 seconds to settle (Eagle’s wait), then WebKit has 10 seconds to hand over the picture. A worker scales it to the usual thumbnail size, takes the palette and writes <name>_thumbnail.png through Library::set_page_thumbnail, and the card is drawn again. The item’s width and height become 1440 and 900. A page that fails or runs out of time is not tried again unless you ask.
Only two things queue a page: Import Links, for the bookmarks it just made (including one kept from the duplicates dialog), and Refresh Thumbnail, for the bookmarks selected. A .url file dropped in or picked up by Auto-Import is left with its plain icon until you refresh it, so a file nobody looked at never causes a page load. Refresh Thumbnail keeps a custom thumbnail, as for other items. When a refresh gets no picture, the bookmark keeps the thumbnail it had and a message says how many pages could not be drawn. An item whose address has changed since it was queued is skipped, and a library that closes drops its pages.
What the page can do is limited:
- Only
httpandhttpsaddresses are queued. A page navigating to anything else, a redirect tofile:included, is refused; a frame may also useabout:,data:andblob:. - Each page gets a new non-persistent
WKWebsiteDataStore, so nothing is written to disk or shared between pages. - A content rule list blocks every load from another site than the page’s, and strips cookies from every request. A page that keeps its styles on a CDN therefore draws without them. Following a redirect to another site still works, since that site then is the page.
- Requests carry WebKit’s default headers and nothing of falcon’s.
- There is no UI delegate, so JavaScript dialogs are answered by WebKit and new windows are refused. Media waits for a user gesture that never comes, so nothing plays.
- At most one page is open at a time, and its web view and window go when it ends.
The queue only ever writes to the item’s own .info folder. The compiled rule list goes through a temporary folder that is removed as soon as it has compiled.
tests/page_shot.rs runs the queue against real WebKit on the main thread, with no Dock icon and nothing on screen, using a page served on a free local port. It checks the picture’s shape and colour, that first-party loads arrive and third-party ones don’t, that no cookie is sent, that dialogs and window.open neither hang nor open anything, that a redirect to a file fails and that a page that never answers times out once. It says it skipped when there is no window server session or WebKit can’t load a local page.
Failed imports
What Eagle does (observed in index.html, app.bundle.js and background.js)
A file that fails in processImage (no extension, empty, a failed copy and so on) is reported as an ADD_ERROR with the error’s message as the reason and added to errorList. The list survives later imports until it is cleared. While it has entries, a toast at the top centre of the window reads “N files failed to import. Click here for details.” Clicking it opens the error modal; its × asks to clear the list. The modal is a table of Name, Source (the file’s path; clicking it reveals the file) and Error Description, with a remove button per row, and four buttons: Clear All (after a confirmation that it can’t be undone), Copy to Clipboard (the files’ paths, put on the clipboard as files), Retry All (every file is imported again with its folders and tags, the list empties and the modal closes) and Close.
How falcon does it
import::Failure::new(path, &error) classifies an error from stage, check_folders or commit_import into a FailKind (missing, not a regular file, empty, format not recognised, unreadable, not enough disk space, staging copy failed, library write failed, unknown folder, other) and keeps the full error text as detail. FailKind::describe gives the reason the app shows. The app’s import thread records a Failure with the ImportOptions used for each file that fails; the list is kept per library until cleared. The “Added N, M failed” message still flashes when a batch ends, and a toast under the toolbar stays while the list has entries, with Eagle’s click and × behaviour. The panel lists the file name, its folder (click to show the file in Finder), the reason (the full error on hover) and per row a Retry button, a Reveal button (disabled once the file is gone) and a remove button, with Clear All (confirmed), Copy to Clipboard (the files, ready to paste in Finder), Retry All (with each file’s own folders and tags) and Close. Eagle has only Retry All and the click on the source; the per-row Retry (the file goes back through the same import with the folders and tags it had, its row leaves the list and returns if it fails again) and Reveal buttons are falcon’s addition.
Auto-import
What Eagle does (seen in js/auto-import/auto-import.js, background.js, preferences.js and my_modules/is-network-drive)
With Preferences ▸ Auto-Import on and a folder chosen (autoImport.enable, autoImport.path, off and empty by default), Eagle watches <chosen>/<library name> (the library’s folder name without .library), making that folder if it is missing, so one chosen folder serves every library. Watching starts when a library loads and when the setting or folder changes, and stops when the library closes or the setting goes off. If the chosen folder no longer exists, an error dialog says so and nothing is watched; the preferences window also forgets such a folder and turns auto-import off. Choosing a folder on a network drive is refused with a warning: the drive check asks for the file system type and refuses smbfs, afpfs and nfs, and smb:// paths.
fs.watch on the folder (debounced by 500 ms), and one pass 2.5 s after watching starts, list the folder. Each entry is skipped when its name contains .tmp anywhere, when it is a folder, when it has no extension or is a .url file, when its format isn’t one Eagle supports (or a plugin thumbnails), or when it is 1,000 bytes or smaller (SVGs: 1 byte or smaller). A file being handled is locked by name. Eagle then checks it every 500 ms until its mtime has stayed the same for six checks in a row (about three seconds), so a copy in progress is waited for, and imports it like a dropped file: into no folder, with no tags, its name without % and emoji. Once the item is added (with or without a thumbnail), the file is deleted from the watched folder. If any files were found, a notice (“N files will start to import”) follows 1.5 s later, when notifications are on and notification.notification.when.autoImport is on (the default).
How falcon does it
crates/falcon-app/src/auto_import.rs holds the rules and the watcher; the pane is prefs::auto_import and the app side App::auto_import_tick in app/import.rs; the FSEvents glue is crates/falcon-app/src/fsevents.rs.
- The same settings and defaults, the same
<chosen>/<library>folder. Eagle watches the folder of the library its window shows; falcon, which can have several libraries open, watches the folder of every open library at once, one watcher each (AutoImport::watchers, by library root), and imports into that library whether it is shown or not. A library in the background imports quietly (no notice); its progress and any duplicate question show when it is brought forward. A watcher starts when its library opens with auto-import on and stops when the library closes or the setting is turned off. Watchers never start under the test harness. - A chosen folder that is gone or on a network volume is refused when picked, and, if it happens later, auto-import pauses with a notice (once, until the preferences change). falcon asks macOS whether the volume is local (
NSURLVolumeIsLocalKey), which refuses SMB, AFP and NFS as Eagle does and also WebDAV and other remote file systems. - falcon doesn’t make
<chosen>/<library>by itself; the pane says when it is missing and its Make It button makes it. - Deleting imported files is a preference, “Delete files after importing them” (
Prefs::auto_import_delete), off by default. Eagle has no such setting and always deletes. The pane shows a red warning line while it is on: “Files in the watched folder are moved to the Trash once they are in the library.” - With it on, a file is removed only after its import committed: the item exists in the library and its original file is in place inside the library. Nothing is removed when the import failed, when the file was held for the duplicate dialog (the dialog still needs it, so it stays in the folder and is remembered), when the file is not a regular file directly inside the watched folder (
<chosen>/<library name>, compared after resolving links; a link is never followed), or when it has changed (size or modification time) since it was handed to the import. A file moved to the Trash is forgotten from the remembered list, so the same file dropped in again later imports again, through the duplicate check. - falcon moves the file to the Trash (
NSFileManager trashItemAtURL:resultingItemURL:error:) instead of unlinking it, so a mistake can be undone from the Trash. Eagle deletes the file. If moving it to the Trash fails, the file stays and is remembered (so it isn’t imported again in a loop); the failure is written to the error log, not shown as an import failure. - With the preference off, files stay in place and falcon remembers each file it handed over (path, size and mtime, the latest 5,000, saved with the app state) and doesn’t import it again while it stays unchanged, across launches too. A file changed in place, or replaced with a different one, is imported again.
- The decision logic (
auto_import::may_remove,after_importandRemoval::finish) is unit tested on temp folders with the deletion injected: a committed import removes the file; a failed or held import, deleting off, a file outside the watched folder or changed since, and a Trash failure all keep it. - Eagle uses
fs.watchwith a half-second debounce. falcon listens to FSEvents through CoreServices (FSEventStreamCreateon a dispatch queue of its own, half a second of latency, file-level events) on the chosen folder, which exists, rather than on<chosen>/<library>, which may not yet; any change there makes the watcher list its library’s folder. If the stream can’t start, the watcher falls back to listing the folder every two seconds (and writes so to the error log), as it also does if the stream ends. A file is imported once its size and mtime have stayed the same for three seconds (Settle); while files wait to settle, the folder is listed again every half second, since a file that stops changing sends no event. Tests inject the events (Watcher::start_withwithWake::Events) on temporary folders and never start a stream; one ignored test (fsevents::tests, run by hand with--ignored) starts a real stream on a temporary folder. - The skip rules are Eagle’s (
auto_import::skip):.tmpanywhere in the name, folders, no extension,.url, 1,000 bytes or smaller (SVGs: empty). falcon also skips hidden files (.DS_Store,._files) and unfinished browser downloads, as its drops do, and imports any other format, as a drop does, where Eagle takes only its supported formats. - The files of each pass go through the usual background import into the library’s root with no folders or tags, so the duplicate check, IPTC keywords, font style tags and the failed-imports list apply. Names are cleaned as for any import (forbidden characters);
%and emoji are kept. - With notices on and “Say when files from the auto-import folder start importing” on (both default), each pass shows “Importing N files from the auto-import folder”.
Progress bar
A bar under the toolbar shows a running import: “Importing 3 of 10 · About 20 s left · name.jpg”. The time left is a straight-line guess from how long the finished items took (progress_eta.rs). It appears once one item is done and two seconds have passed, rounds seconds to the nearest five above ten, and then counts in minutes, or hours and minutes. The bar repaints once a second, so the guess keeps moving while a large file is still being read.
Dock progress
While the background import queue runs (the one the sidebar’s task status shows as Processing or Paused), the Dock icon carries a progress bar along its bottom edge, the share of files finished, as Eagle’s setProgressBar does. Like Eagle, the bar goes away once the import is 97% done, and it is cleared when the queue empties or the library closes. The progress modal (ui/dialogs/progress.rs) drives the same tile through its own slot: while the modal is shown it wins, and when it ends an import still running takes the tile back. dock_progress::set_import(Some((done, total))) is the import’s slot; the combining rule is the pure dock_progress::combine, and the tile is only redrawn when the combined value changes.
Thumbnails
What Eagle does
Every claim is from the renderer code in app/js/thumbs/ and app/js/utils/compress.js.
Whether an item gets a thumbnail
- For png, jpg, bmp and jxl,
noThumbnailis true when width and height are at most 960 and the file is at most 15,000,000 bytes (observed). The original is then displayed directly and no thumbnail file is kept. - A png of at most 960 px on both sides but over 1 MiB still gets a thumbnail (observed).
- webp and animated avif have their own checks (observed that they exist; not read). Falcon assumes the same rule for webp and gif (inferred).
- SVG always renders a WebP, which is used for the palette. Under 100,000 bytes,
removeThumbnailandnoThumbnailare both set and the file is deleted after the palette is computed. At 100,000 bytes or more the thumbnail stays and neither flag is written. A render error setsnoThumbnailonly (observed). - The flags are absent on items that simply have a thumbnail (observed in the fixture).
Size
- The limit is 320 for ordinary images, 480 for documents, design files and 3D formats, and 512 for ico and icns (observed).
- Nothing is resized unless the longer side is over the limit (observed).
- Then the short side becomes the limit. For landscape (
w > h) the height is the limit and the width isfloor(limit * w / h). Otherwise the width is the limit and the height isfloor(limit * h / w)(observed). So 2000x1000 becomes 640x320, and 1000x200 becomes 1600x320, which is an upscale of the short side. This looks like an accident, not intent (inferred). - Large sources are halved on a canvas until within 1.5x of the target, with high-quality smoothing, then drawn at the target size (observed).
Encoding
- Lossy WebP at quality 0.75, written to
<name>_thumbnail.png. The bytes are WebP despite the name (observed). - If a side exceeds 16383 px, JPEG at 0.7 is written under the same name (observed).
- All of this is done by Chromium’s image decoder and canvas (observed). The exact output bytes therefore cannot be reproduced.
Audio waveforms (observed)
- An audio item’s thumbnail is its waveform.
- Eagle draws it with wavesurfer.js in an off-screen 280×140 container. It uses colour #0072EF, normalised to the loudest peak, on a transparent background.
- The canvas is saved as PNG and then compressed to WebP like any other thumbnail. Sides are multiplied by the display’s pixel ratio, so Retina renders are probably larger (inferred).
- The item’s
widthandheightare 280 and 140. - Files longer than an hour only have their first 10 MB loaded.
- No peak data is cached on disk (no
waveformorpeaksfile in the item folder). The player re-decodes the file when it shows one.
Font specimens (observed)
A font’s thumbnail is a 600×600 specimen. The item’s width and height are 600:
- The background is white and the text is #333.
- The font is loaded as a web font, and html2canvas captures the block as JPEG at quality 1. It is then compressed to a 320×320 WebP.
- For Latin fonts there are three lines,
ABCDEFG,abcdefgand0123456, at 90 px with line height 1.35. They sit left-aligned in a 400 px block centred on the canvas. - Fonts that support Chinese, Japanese or Korean get two lines of sample characters at 100 px (one line at 90 px for Korean), then
AaBbCcDdand01234567at 82 px, in a 380 px block. - The script is chosen from
fontMetas.supportand the languages ofpostScriptName. - Missing glyphs fall back to an outline font.
Custom thumbnails (observed)
From the context menu’s “Custom Thumbnail” entries and the background window’s set-custom-thumbnail handler:
- The command is hidden for tif, jpg, png, bmp and webp items, whose file is already their picture. Every other format can have one.
- The file dialog offers jpg, jpeg, png, bmp and webp. A file over 10,000,000 bytes is refused with a warning. “From clipboard” also takes a copied gif file, or saves a copied image as PNG first.
- The chosen file is copied byte for byte to
<name>_thumbnail.png. It is not resized or re-encoded, so a custom thumbnail can be a large JPEG under a.pngname. - The item’s
widthandheightbecome the picture’s size, even for a video or PDF (the HTTP API may pass its ownwidthandheight). - The palette is computed from the new file.
noThumbnailandnoPrevieware deleted, andcustomThumbnail: trueis set. Being a new key, it lands at the end of the item.removeThumbnailis left alone.- Grid cards with a custom thumbnail get a
custom-thumbnailCSS class. The viewer and the plugin API use the thumbnail in place of the original for formats Eagle can’t show itself. - Refresh Thumbnail skips an item whose
customThumbnailis set. With several items selected falcon refreshes them on a background thread behind a progress modal with a Cancel button that stops before the next item, as Eagle shows a progress dialog for it. “Reset Thumbnail” (shown only when it is set) deletes the key and then refreshes the thumbnail.
GIF frame thumbnails (observed)
From the GIF player’s “set as thumbnail” command and the background window’s regenerate-gif-thumbnail handler:
- The frame on screen is drawn on a canvas. When its short side is over 480 px it is scaled so the short side is 480; smaller frames are kept at their size.
- The canvas is exported as PNG (
toDataURL()with no type) and written to<name>_thumbnail.png, replacing the thumbnail. - The palette is computed again from that file. Nothing else in the item changes: no
customThumbnail, andwidth,heightandnoThumbnailare left alone, so Refresh Thumbnail later draws the first frame again.
Falcon’s Library::set_frame_thumbnail(id, &frame) does the same, with thumbnail::frame_thumbnail_size for the size (Catmull-Rom scaling, where Chromium’s canvas scaling may differ by a few levels) and the image crate’s PNG encoder.
Refresh Thumbnail (observed)
The regenerate-thumbnail queue reads the original’s size, birth time and mtime again (size, btime, mtime), then runs the same per-format generator as import, so width, height and the flags are measured again. The palette is then queued the same way as at import. A format Eagle no longer recognises loses its thumbnail, palettes, width and height and gets noPreview: true. When a re-measured image becomes small enough for noThumbnail, the jpg generator sets the flag but does not delete the old thumbnail file (inferred harmless, since Eagle then shows the original).
Crop and rotate (observed)
From the viewer’s rotateImage, cropImage and saveCrop, js/utils/rotateImage.js and my_modules/image-cropper:
- Where. Both are buttons in the viewer’s toolbar. Rotate (⇧R) turns 90° anticlockwise, or clockwise with ⇧ held while clicking. It is offered for jpg, png, webp and bmp. Crop is offered for jpg, png and webp, and is disabled for animated images.
- Rotation is written to the file when the
imageRotateModepreference iswrite, which is the default. The other mode only turns the view. - JPEG rotation is lossless. Only the EXIF Orientation tag changes, following a table of the eight orientations. The compressed image is untouched.
- Other formats are re-encoded. PNG, WebP and BMP are drawn rotated on a canvas and exported in the same format, at quality 1 for WebP. Animated PNG is refused, and AVIF can’t be written.
- Cropping is always re-encoded. The crop box is cut out on a canvas and exported as PNG, JPEG or WebP, with a quality setting of 100 passed to Eagle’s canvas helper (inferred to mean the best quality). The canvas drops the EXIF block, so a JPEG’s orientation ends up in its pixels. Eagle shows the result and asks before saving.
- Writing. Crop and rotate both overwrite the original in place and keep its name. Crop first copies it to
<file>.bkand restores that if the write fails. Rotate writes<file>.tmpand then the file. - Falcon’s writing. Falcon does not write in place. Rotate, flip and crop put the new bytes in
<file>.staged, a copy of the original so that permissions and extended attributes stay, sync it and rename it over the original, keeping the birth time on macOS. There is no.bk. - Afterwards. Eagle refreshes the thumbnail (see above), which updates
size,btime,mtime,width,height, the thumbnail and the palette. The item edit also bumpslastModified;modificationTimestays. Folder covers that show the item are redrawn. - Undo. There is none. The overwritten pixels are gone.
Flip (observed)
From the viewer’s flipImage and js/utils/flipImage.js:
- Where. The flip button in the viewer’s toolbar mirrors horizontally. With ⇧ held while clicking, it mirrors vertically. For video and audio the same button only mirrors the player. The file is written under the same
imageRotateMode: writepreference as rotation. Otherwise only the view flips. - JPEG is lossless. Only the EXIF Orientation tag changes, from lookup tables for horizontal, vertical and both-ways flips: horizontal maps 1↔2, 3↔4, 5↔6, 7↔8, and vertical maps 1↔4, 2↔3, 5↔8, 6↔7. A missing tag counts as 1. The file is written to
<file>.tmpfirst, then over the original. - PNG, WebP and BMP are re-encoded. The image is loaded, drawn mirrored on a canvas and exported in the same format (quality 1 for WebP, a canvas-to-BMP helper for BMP). AVIF is accepted, then fails because it can’t be written. Other formats are refused. Eagle doesn’t check for animation here (inferred: an APNG would lose its frames).
- Afterwards. Eagle queues
regenerate-thumbnailfor the item, as rotation does.
Falcon’s Library::flip_item(id, Flip::Horizontal | Flip::Vertical) follows this, with rotation’s semantics (the file is replaced atomically, see Writing above). A JPEG gets transform::flip_orientation, which composes the mirror with the current orientation and reproduces Eagle’s tables. PNG, WebP and BMP are mirrored and re-encoded with the same encoders falcon uses for rotation. Animated PNG and WebP are refused rather than flattened. The item is then refreshed (size, times, dimensions, thumbnail, palette), lastModified is bumped, and the open undo transaction is marked irreversible. transform::can_flip(ext) lists jpg, jpeg, png, webp and bmp.
How falcon does it
The thumbnail module holds the rules as plain functions: plan_raster, plan_svg, target_size, max_side, resize and encode.
- Rules. Falcon applies the flag, size and encoding rules above exactly, including the short-side quirk and the PNG exception.
- Formats. png, jpg/jpeg, webp, gif (first frame), bmp and svg. Other extensions have no thumbnail. The 480 and 512 limits are in the table, but no format that uses them can be decoded yet. The list of 480 extensions is partial.
- Decoding. The
imagecrate. EXIF orientation is applied, sowidthandheightare the displayed size, as with Eagle’s<img>measurement. - Resizing. One Lanczos3 resize straight to the target, with no halving steps. Pixels differ slightly from Eagle’s.
- Encoding. The
webpcrate (libwebp) at quality 75, always with an alpha channel. Past 16383 px,image’s JPEG encoder at quality 70. - SVG. Rendered with
resvg, directly at the target size. System fonts are not loaded, so text in an SVG does not render. A small SVG is rendered at its natural size, because its thumbnail only feeds the palette. Width and height are the SVG’s own size, rounded up. - Text. A
.txtfile gets no thumbnail, as in Eagle. Its importer (thumbs/txt.js) instead stores the start of the text in the item record undertext: the first 32 Ki UTF-16 code units, read as UTF-8. falcon does the same (falcon_library::text::preview, cut at a character boundary), writes the record in Eagle’s key order for text items (nowidth,heightorpalettes;textjust beforelastModified), refreshestextwhen the editor saves, and reads it again on Refresh Thumbnail. The grid draws the card from it (see The desktop app). - Reading back.
thumbnail::decodesniffs the content, so it reads a thumbnail even though its name ends in.png. - QuickLook. Formats falcon can’t decode are rendered with
qlmanage. A render that takes more than 30 seconds is abandoned, since qlmanage can hang on malformed files. - Web archives. QuickLook has no MHTML renderer, so an
.mhtmlfile is unpacked (falcon_library::mhtml) into a temporary folder that is removed afterwards, and QuickLook renders itsindex.htmlthere with the archive’s own styles and images; QuickLook doesn’t fetch anything from the network. The top of the render, cut to 16:10, is the thumbnail, and the item records 1440 × 900, the window Eagle captures pages in. An archive that can’t be unpacked gets no thumbnail. - Previews inside the file. When QuickLook gives nothing (no generator for the app is installed, or the file is one it can’t read), import uses the preview inside the file, so the item still gets a thumbnail and a size.
- RAW photos (the extensions in
exif::RAW_EXTENSIONS, among them crw, mrw, orf, raf, raw, srw and x3f): the same embedded JPEG the viewer shows (thumbnail::raw_preview), turned upright by the file’s orientation.widthandheightare the camera’s pixel size, turned the same way. They come from the sizes the file states in its EXIF or TIFF tags (exif::stated_sizes), taking the largest, and only one at least as big as the preview, since a thumbnail IFD states a small size; failing that, from Spotlight; failing that, the preview’s own size. A RAW file with no embedded JPEG (some Minolta MRW files hold only sensor data) gets no thumbnail. - Sketch (
previews/preview.png), XMind (Thumbnails/thumbnail.png) and Keynote (preview.jpg,QuickLook/Thumbnail.jpgorData/st-*slide pictures, also under a<name>.key/folder): read from the file’s zip (zip_preview). Many Keynote files have none: the 2018 sample holds onlyIndex.zip, with.iwaparts and no picture. - IDML (
zip_preview): athumbnail*orpreview*picture in the zip, else the base64 JPEG in thexmpGImg:imageelement ofMETA-INF/metadata.xml. - Illustrator (ai, ait) saved PDF-compatible: page 1 drawn by PDFKit (
falcon_pdf), with the page’s size in points as the item’s size. Otherwise it is read like an EPS. - EPS (
binary_preview::eps): the TIFF of a DOS EPS binary header, else a%%BeginPreviewbitmap, else an Illustrator%AI7_Thumbnailblock (a palette, thenRLEand palette indexes, accepted only when the pixels come to exactly width times height). An EPS with none of these gets no thumbnail, since falcon has no PostScript interpreter (Ghostscript is AGPL). - InDesign (indd, indt;
binary_preview::indesign): the base64 JPEG in the XMP packet’sxmpGImg:image, the largest when a file holds several. - Blender (
binary_preview::blend): theTESTblock’s RGBA rows, in plain and gzip files. A zstd-compressed file (Blender 3.0 and later with compression on) gets none, since no zstd decompressor is in the dependency tree. - SketchUp (
binary_preview::skp): the first PNG in the file at least 64 px on its short side, which comes before the material textures, or the thumbnail entry of a zip. - AutoCAD (
binary_preview::dwg): the image section the seeker at byte 13 points to, a DIB or a PNG. WMF previews are not read. - All of these files are untrusted: reads are bounded (16 MiB for a picture, 1 GiB for a scan, linear in the bytes read), every stated length and offset is checked, pictures decode under size and memory limits, and anything malformed means no thumbnail. The item’s size is the preview’s, except a PDF-compatible Illustrator file’s.
- The viewer draws the thumbnail from the start for a format QuickLook renders, while QuickLook runs and if it fails (
loader::stand_in_job), so an item with a thumbnail never opens to the bare file icon. - In the viewer and preview windows,
qlmanageruns on one worker of its own in eachLoader, so a run can’t hold up anything else. On this kind of Mac it hangs instead of failing for a format with no generator (blend, dwg, indd, skp, sketch, xmind, idml, eps and PostScript AI were seen), while a real generator answers a small file in about 0.3 s. Every job starts on a fast worker, which tries falcon’s own decoders (a PSD’s merged image, a RAW’s preview, ImageIO) and moves the job to the QuickLook worker only if they can’t give a picture. A fast worker takes thumbnail stand-ins (the preview tier) before anything else. The grid’s thumbnails are ordinary fast jobs. - Leaving an item drops its queued jobs and kills its running
qlmanage, regions included. That isn’t a failure, so coming back asks again. Closing a preview window or a library kills its run too. - A run gets 30 seconds, or 5 for the formats in
import::embedded_first, which rarely have a generator. A run that runs out marks the format (by its canonical name) for the rest of the session and for every loader: QuickLook isn’t tried on it again, the full tier of its items is the thumbnail at once, and an item with no thumbnail goes straight to the “can’t preview” page. A run that merely fails isn’t remembered, since it costs little and may be about that one file. A PDF-compatible AI still draws sharp zoomed parts through PDFKit after that, and a PSD through its merged image. Import still uses its own 30-second run, as nobody waits on it in the viewer.
- RAW photos (the extensions in
- Video through FFmpeg. Video macOS can’t read (mkv, webm, avi, wmv, flv, ts and the rest) is thumbnailed through FFmpeg when it is installed, before QuickLook, and other video falls back to it after QuickLook fails. As Eagle’s mpv fallback does, the picture is taken ten seconds in, or a third of the way through a shorter clip, and written as a 480-wide JPEG to
<name>_thumbnail.png; when that picture is black, up to five later ones are tried. See Video playback. - Audio.
waveform::peaksdecodes the file withfalcon_library::audio(the decoder the player uses; see Audio formats in The desktop app) and returns (min, max) pairs of the mono mix for any number of buckets.waveform::renderdraws them one column per bucket, normalised and mirrored around the middle.- Import draws 280 buckets at 280×140 in #0072EF on transparency, sets
widthandheightto 280 and 140, and stores that as the thumbnail. There is no pixel-ratio scaling. - Nothing is cached, as in Eagle. If decoding fails, QuickLook is tried instead.
- Fonts.
fontpreview::renderloads only the font into afontdb, lays out the specimen as SVG text and draws it with resvg at 600×600. Import then scales it to 320×320.- Baselines follow CSS: half the leading, then the font’s hhea ascent.
- The layout and sizes are Eagle’s. The CJK sample words are falcon’s own.
- Missing glyphs are left blank instead of drawn in an outline font.
- The result is opaque RGB, as Eagle’s JPEG capture is.
- woff2 is unpacked first. A TrueType woff2 whose glyphs are stored packed (the usual case) has no outlines after unpacking, so it falls back to QuickLook instead of drawing a blank card.
- 3D models.
- obj, stl, ply, glb, 3mf, fbx, dae, 3ds and ifc are drawn by
model3d::rasterinstead of QuickLook, as Eagle renders models in its own viewer at import. - The model is drawn in the viewer’s opening view (Y up, fitted, Eagle’s lighting and default colour) on a transparent 1080×1080 canvas at 2× supersampling. The empty border is cropped and 40 transparent pixels are added on each side.
- Textures are drawn: glb, obj, fbx, dae and 3ds meshes sample their diffuse picture per pixel, bilinearly in linear light at perspective-correct texture coordinates, times the tint, and pixels under a glTF
MASKcutoff are left out. Normal, bump and emissive maps and see-through meshes are drawn too, far meshes first. Pictures are read only from next to or below the model, decoded at most 4096 px a side and 64 MB per model; a missing one leaves the material colour (see Textures in the app). - That image is the thumbnail, not scaled down (
max_sideis 1160 for these formats), andwidthandheightare its size, as in Eagle. - A model that can’t be read falls back to QuickLook. IFC is drawn through web-ifc when falcon is built with it, so swept, CSG, boolean and b-spline solids reach the thumbnail with their openings cut; without web-ifc an ifc file with no tessellated geometry falls back to QuickLook.
- obj, stl, ply, glb, 3mf, fbx, dae, 3ds and ifc are drawn by
- Custom thumbnails.
Library::set_custom_thumbnail(id, path)follows Eagle: the same format and 10 MB rules, a byte-for-byte copy,widthandheightfrom the picture, a new palette,noThumbnailandnoPreviewremoved andcustomThumbnail: trueappended. It also refuses a file it can’t decode.reset_custom_thumbnailremoves the key and redraws.thumbnail::has_custom_thumbnailandcustom_thumbnail_allowedhold the rules; jpeg and tiff count as jpg and tif. - Refresh Thumbnail.
Library::refresh_thumbnail(id)re-reads size and file times, then redraws with import’s own pipeline and recomputes the palette, or for audio thebpm(see Import). It returns false and changes nothing for an item with a custom thumbnail. The new thumbnail replaces the old file only once it has been drawn, so a failed redraw keeps the old one. Unlike Eagle, a thumbnail that is no longer needed is deleted, because falcon shows a thumbnail file whenever one exists. - Crop, rotate and flip. In
transform:Library::rotate_item(id, quarter_turns)(clockwise; negative is anticlockwise),Library::flip_item(id, Flip)(see Flip above) andLibrary::crop_item(id, PixelRect), in the image’s displayed pixels.- Formats: rotate takes jpg, png, webp and bmp, crop takes jpg, png and webp. GIF, SVG, PDF and the rest are refused, and so are animated PNG and WebP.
- A JPEG rotation rewrites only the EXIF orientation. An existing tag is changed in place. EXIF without the tag gets a copy of IFD0 with the tag added, appended to the block. A JPEG without EXIF gets a minimal block after its JFIF header. The compressed image data is copied unchanged.
- Everything else is decoded with the
imagecrate, turned or cut, and encoded again in the same format: PNG, JPEG at quality 100 with no EXIF (as Chromium’s canvas does), BMP, and WebP through libwebp. Eagle’s canvas exports WebP at quality 1, which falcon takes to be lossy quality 100 (inferred); falcon keeps a lossless WebP lossless. - The file is replaced atomically through
<file>.stagedrather than overwritten in place, and its birth time is kept on macOS. Then the item is refreshed as by Refresh Thumbnail and saved throughupdate_item. - Undo: falcon’s undo can’t restore pixels, so these edits mark the open undo transaction irreversible, like a permanent delete. In the app that clears the undo history rather than restoring dimensions that no longer match the file.
Colour palettes
Every item’s palettes field lists its dominant colours with each colour’s share of the image:
"palettes": [{"color":[106,147,68],"ratio":45},{"color":[5,67,121],"ratio":31},{"color":[160,181,138],"ratio":2.22}]
Eagle uses these for colour search and for the swatches in the inspector.
What Eagle does
Which image it analyses (observed)
Eagle doesn’t analyse the original file directly. It uses:
- the item’s thumbnail, normally;
- the original file, when the item has
noThumbnailbut notremoveThumbnail(small PNG, JPG, BMP and JXL files); - a temporary thumbnail for small SVGs (
removeThumbnail), deleted once the palette is stored.
The image is drawn onto a canvas at most 360 px wide. It is never enlarged, and the aspect ratio is kept.
Sampling (observed)
Eagle doesn’t count every pixel of larger images. It takes every Nth pixel, with N chosen from the size of the RGBA buffer:
| Buffer size | Every Nth pixel |
|---|---|
| under 100 kB | 1 |
| under 1 MB | 10 |
| under 10 MB | 100 |
| under 100 MB | 1000 |
| larger | 10000 |
A 320 × 320 thumbnail is 409,600 bytes, so Eagle samples every 10th pixel. Pixels with alpha below 170 are skipped.
Median cut (observed)
The colours come from modified median cut quantization (MMCQ), the algorithm behind the color-thief library. It is not k-means.
- Histogram. Each sampled pixel is reduced to 5 bits per channel, giving 32 × 32 × 32 bins, and counted.
- One box. A box is drawn around all the colours that occur.
- Split by population. The box with the most pixels is cut along its longest side near the pixel median. This repeats until there are 9 boxes (75% of 12), or 5 cuts have been made.
- Split by population × volume. The boxes are re-ranked by pixel count times box volume, so large sparse regions also get cut, for up to 5 more cuts.
- Colour of a box. Each box’s colour is the pixel-weighted average of its bins’ centres.
The 5-cut limit on each pass means a palette rarely has more than about 11 colours, even though 12 are requested.
From boxes to ratios (observed)
ratio= the box’s pixel count divided by the number of sampled pixels × 100, rounded to two decimals.- A ratio of 5 or more is truncated to a whole number. That is why the example mixes
45with2.22, and why the ratios don’t add up to exactly 100. - Colours below 0.25 are dropped, and the rest are sorted from largest to smallest.
- The top 5 are always kept. The 6th needs a ratio above 0.1, and later ones above 0.3.
A quirk (observed)
The code that finds the starting box tracks each channel’s minimum and maximum with if … else if. A value that lowers the minimum is never also checked against the maximum. For the first pixel, every channel lowers the starting minimum of 31 (unless it is 31 itself), so the first pixel never sets a maximum. An image with a single counted pixel therefore gets an empty box and no palette. For real images this almost never matters.
Refresh Palette (observed)
The “Refresh Palette” menu entry (and /api/item/refreshPalette) sends the items to the colour analyser’s queue:
- The source is the original when the item has
noThumbnail, otherwise<name>_thumbnail.png. TheremoveThumbnailcase doesn’t matter here: a small SVG hasnoThumbnailtoo, so its SVG file is drawn. - The analysis is the same as at import, at most 360 px wide.
- Only
palettesis assigned on the stored item, so it keeps its place among the keys.processingPaletteis a temporary key that is removed again. - Audio files are skipped (mp3, wav, flac, ogg, aac, m4a).
- Items whose image would exceed 30 megapixels when scaled to 480 px wide are not analysed.
How falcon does it
falcon_library::palette::extract(&DynamicImage) reproduces the whole pipeline: the 360 px analysis image, the sampling step, MMCQ with Eagle’s control flow, and the rounding and filtering rules.
The port keeps the details that affect the output:
- The bounds quirk. It is reproduced exactly.
- Box ordering. Boxes live in a queue that is sorted only when popped, and only if something was pushed since the last sort. Ties keep their insertion order, so the colour order stays the same.
- Number format. Ratios are written as JavaScript would print them,
3and not3.0, so the JSON stays byte-identical.
Checked against Eagle
The matches_eagle_on_fixture_thumbnails test recomputes the palette for all 19 fixture items that have a thumbnail. Eagle computed its palettes from those same files.
| Thumbnail width | Items | Result |
|---|---|---|
| 360 px or less (no resize) | 12 | Identical to Eagle: every colour and every ratio |
| over 360 px (resized first) | 7 | Same dominant colour and ratio. Smaller colours differ slightly |
The quantizer is therefore an exact match. The remaining difference is the resize step. Chromium scales canvases in Skia with fixed-point arithmetic. Falcon uses one bilinear sample per pixel. Of six filters tried, point-sampling filters came closest and area-averaging filters (box, triangle) did worst, which fits Chromium sampling without mipmaps. Matching Skia bit for bit isn’t worth it here.
Refreshing
Library::refresh_palette(id) recomputes palettes with extract, from palette::refresh_source: the original for noThumbnail items (a small SVG is rendered at its own size), otherwise the thumbnail. Audio is skipped and the result is false. Only the palette changes, and the edit goes through update_item like any other. The 30-megapixel check is not applied.
Search and filters
Eagle has no search index. Every search is a linear pass over the items held in memory, and so is falcon’s (falcon_library::search).
Keyword search (observed)
Eagle joins an item’s searchable text into one lower-case string and tests each term as a substring. The text includes:
- the text content of a text item, and the camera (
rawMetas.camera), always - the name
- the extension with a leading dot, such as
.png - the URL, only for queries of at least 2 characters
- the notes
- the text of every comment, region and video comments alike
- for a font, its full name (the
zhentry first, elseen), and, for queries of at least 3 characters, its wholefontMetasas JSON - the tags
- the name and description of every folder the item is in
There is no accent folding: e doesn’t match é.
Search Scope (observed)
The caret beside the magnifier in the search box opens the Search Scope menu. It has a checkbox each for Name, Folder Name, Folder Description, Extension, Tags, URL, Comments and Notes, all on by default. An unchecked part is left out of the search text. The text content, camera and font names are not in the menu and are always searched. falcon remembers the choice across launches, shared by every library (SearchScope, kept by crates/falcon-app/src/search_state.rs). search_text_in builds the text for a scope, and search_text uses every part.
Search All
⌥⌘F (Find ▸ Search All) leaves the viewer, opens the All page and focuses the search box, so the search covers the whole library.
The query syntax:
| Syntax | Meaning |
|---|---|
cat dog | both terms must match |
cat OR dog, cat || dog | either term matches |
-bird | the term must not match |
"black dog" | a quoted phrase, matched as one substring |
( … ) | a group |
Eagle doesn’t evaluate a real expression tree. It flattens the query into three lists: terms that must all match, OR groups where one member must match, and terms that must not match. So dog || (cat black) means dog OR the literal text cat black. Falcon reproduces this flattening.
If the terms fail, Eagle makes one last attempt: it tests the whole query as a single substring.
Typo tolerance (falcon only)
Eagle has no typo tolerance: an item matches only when the text is in it. falcon’s app can add typo tolerance on top of Eagle’s matching (Query::fuzzy_score) with Preferences ▸ General ▸ Search ▸ “Also find near misses when searching” (Prefs::typo_search). It is on by default, a deliberate falcon extra; turned off, the search box gives Eagle’s exact-substring results. The HTTP API always keeps Eagle’s exact behaviour.
When nothing matches a search or the active filters, the grid says so in the middle (“No Matches”, with a hint to try another keyword or loosen the filters), as Eagle shows its own nothing-found message with a search illustration.
With the preference on:
- An item that matches the way Eagle matches costs 0 edits.
- Otherwise, each AND term, and one member of each OR group, must match the start of some word in the item’s text within a small number of edits. The edits are insertions, deletions, substitutions and swaps of two adjacent letters (optimal string alignment distance).
- Words are runs of letters and digits, so
photography_01.jpghas the wordsphotography,01andjpg. - A term may match a whole word, or a word prefix at least as long as the term. So
tigrfindstiger, but nottiramisuthroughtir. - The edit allowance depends on the term:
| Term | Edits allowed |
|---|---|
| under 4 characters | 0 |
| 4–7 characters | 1 |
| 8 or more characters | 2 |
| contains a digit, or is a quoted phrase | 0 |
- Common Latin accents fold in typo matching, so
cremefindscrème. - NOT terms (
-bird) stay exact. - Order: exact matches come first, in the chosen sort order. Typo matches follow, fewest total edits first, and in the chosen sort order within the same count.
- Speed: a word is skipped without computing the distance when the term has more letters missing from the word entirely than its allowance. On 20,000 synthetic items a search takes 6–12 ms in a release build.
Filter bar
The bar under the toolbar shows one chip for each filter that is pinned or on. Eagle’s default pins are Color, Tags, Folders, Shape, Rating and Type. The + button lists every filter the library can use, each with a pin that keeps it in the bar or takes it out, and clicking a name opens that filter. Duration, BPM, Camera and Font are only listed when some item has a value for them. The pins are remembered across launches with the search scope. Chips can be dragged sideways to reorder them (a press starts a drag once the pointer has moved 10 points, so a click still opens the chip); the new order, kept as the full list of filters as Eagle’s eagle.filter.toolbar.orders does, is remembered across launches and is also the order of the + menu.
A filter that is on always shows its chip, pinned or not, and a chip opened from the + menu or a key stays until its menu closes. The code is in crates/falcon-app/src/ui/filter_bar/mod.rs and ui/filter_bar/media.rs.
- Clear appears while any rule is on. It switches every rule off and also releases the Lock, as Eagle’s reset does. The search text stays.
- Clear Filter (⇧⌥⌘F, Find ▸ Clear Filter) does the same as the Clear button.
- Lock appears while a rule or a search is on. Opening another view (a folder, a smart folder, a tag, All, the Trash, going back or forward) switches every filter rule off and empties the search box, unless the Lock is on. Opening the view that is already open changes nothing.
- Filter Sidebar (⌥⇧F) shows the sidebar and focuses its folder filter box.
Find ▸ Open Filter and the keys ⌥F (Folders), ⌥T (Tags), ⌥C (Color), ⌥S (Shape), ⌥R (Rating), ⌥D (Date) and ⌥E (Type) show the filter bar and open one filter’s menu. The Size, Dimensions, Duration, Comment, Notes, URL, Font, BPM and Camera rows exist in the menu and under Preferences, Shortcuts, and have no key.
Filter rules (observed)
Every active rule must pass.
- Tags and folders: each has Any, All and Exact logic. Eagle has an OR-mode quirk: under Any, an item that has an included tag passes even if it also has an excluded tag. The folder filter matches folder ids exactly and doesn’t include subfolders.
- Type: accepts an extension, or a group such as video, audio, font, word, excel or powerpoint.
- Size, width and height: inclusive ranges.
- Rating: any of the selected star values, with 0 meaning unrated.
- Shape: see below.
- Notes, URL and Comment: has a value, has no value, or contains all of the given keywords.
Comment (observed)
The rule reads an item’s region and video comments (comments[].annotation). Has needs at least one comment, None needs no comment, and Keywords needs every keyword, case-insensitively, to occur in one of the comments (not necessarily the same one). Notes and URL use the same three choices over one text.
Size and Dimensions (observed)
- Size takes a minimum and a maximum in KB or MB (1 KB is 1024 bytes, 1 MB 1,048,576 bytes). A bound may have a fraction; it is converted to bytes and truncated to a whole byte.
- Dimensions takes a minimum and a maximum for the width and for the height, in whole pixels (fractions are truncated).
- Each bound is inclusive and an empty bound is open.
Shape (observed)
| Shape | Passes when |
|---|---|
| Landscape, Portrait, Square | width greater than, less than or equal to the height |
| Panoramic (landscape and portrait) | the long side is at least 2.5 times the short one |
| 4:3, 3:4, 16:9, 9:16 | width / height equals the ratio exactly, as floating-point numbers |
| Custom W:H | width / height equals W / H exactly |
- The ratios compare floats for equality, so 1366 × 768 is not 16:9 and 1920 × 1080 is.
- In the custom ratio a side left at 0 (unset) lets every item through.
- The ticked shapes are OR-ed, the custom ratio included.
- The menu lists a shape with its count and hides one with no items (unless it is ticked).
shape_countscounts as Eagle does: only items with a width that are not audio or fonts, each in exactly one of landscape, portrait, square, panoramic landscape and panoramic portrait, and in at most one of the four ratios. - Rating and Type hide options with a count of 0 in the same way (unless the option is on).
Tags chip
The menu has the Any, All and Exact choice and a search box. Without a search, tags in a tag group come first, in the order of their first group, then the others, each in plain code-unit order. With a search the tags are ranked by name (see Name ranking below). A sidebar in the menu switches between All Tags, Selected (the included and excluded ones) and each tag group that has tags in the list. Untagged is a row of its own on the All Tags page. Select all, on a group’s page, includes every tag listed there. A click includes a tag and a right-click excludes it. The chip reads the included names, then the excluded ones with a leading minus.
Folders chip
The menu lists the folders that hold items, in tree order and indented by depth, with their counts, and Uncategorized first. A search box ranks them by name and shows them flat. A click includes a folder and a right-click excludes it, Uncategorized included. Excluding Uncategorized is SetFilter::exclude_empty, which drops items that are in no folder under Any, All and Exact alike. Under Any, an item passes when it has an included folder, even if it also has an excluded one, or when excludes are set and it has none of them; with exclude_empty set, an item in no folder counts as excluded. All and Exact need every included folder and none of the excluded ones.
Type filter (observed)
type_included follows Eagle’s order and its early answers:
- An item whose own extension is included passes.
- If the video group is included, the answer is final: the item passes only if it is a video. So including both video and audio lets only videos through.
- A
urlentry takes bookmarks without a site. The youtube, vimeo and bilibili entries take bookmarks whosemediumis that site. - If the audio group is included, the answer is final: the item passes only if it is audio.
- The word, excel and powerpoint groups, then font.
An excluded extension or group, or an excluded url or site, removes an item. The video group holds ts, 3gp, mp4, mov, m4v, webm, mkv, avi and similar; audio holds mp3, wav, flac, ogg, aac and m4a; font holds ttf, otf, ttc, woff and woff2.
The menu lists the extensions present, in code-unit order, then the built-in groups, with counts and a search box. A bookmark with a site counts under the site. Eagle lists the font, video and audio groups but never counts them, so its panel hides them. falcon counts them (type_counts), so they show.
Dates (observed)
Eagle’s panel has two date rules: Date Imported reads modificationTime, and Date Modified reads mtime, falling back to modificationTime when mtime is 0. All times are integer milliseconds. There is no Date Created rule in the panel; btime is only used by smart folders. Falcon adds a Date Created rule (Filter::created, reading btime with the same fallback) for the app; that part is falcon’s own.
Each rule has presets, a calendar range and a month list:
| Preset | Passes when |
|---|---|
| Today | the time is after local midnight today |
| Yesterday | the time is after local midnight yesterday and before local midnight today (both ends exclusive) |
| Last 7, 30, 90, 365 days | now − time is less than N × 24 hours |
- “Yesterday” is midnight minus exactly 24 hours, so a DST change shifts it by an hour. Eagle’s midnight also keeps the current milliseconds (it calls
setHours(0, 0, 0)without the ms argument); falcon uses exact midnight. - The range holds the local midnights that start the first and last picked days. An item passes when
start ≤ time ≤ end + 24 h, so the last day is included whole, plus the exact next midnight. The range is ignored unless both ends are set. - The presets and the range are OR-ed: any one of them is enough.
- The month list (
YYYY/MMkeys, local time) is a separate rule. When months are selected, the item’s month must be one of them, in addition to the presets. - Eagle’s Date Modified range reads the Date Imported range’s values, which looks like a bug. In the app both rules share one picker, so the effect is the same. Falcon gives each rule its own range.
Duration (observed)
- Reads
duration, in seconds, which may have a fraction. - Min and max are entered in seconds, minutes or hours. Each bound is converted to seconds and truncated with
parseInt. The test isduration ≥ minandduration ≤ max, both inclusive. - Any numeric bound, including 0, makes the rule active. Items with no
durationthen fail.
BPM (observed)
- Reads
bpm, the tempo of audio items, which may have a fraction. - Min and max are each truncated with
parseInt. The test isbpm ≥ minandbpm ≤ max, both inclusive. - Any numeric bound makes the rule active. Items with no
bpm, or0, then fail. - In saved filters the rule is
bpm: {min, max}, with each key present only when set. Dates, duration, camera and font are saved too; see thesaved-filters.jsontable in Library format.
Camera (observed)
- Reads
rawMetas.camera, which Eagle only writes for RAW photos (see Import). - The options are the distinct camera values across all items, sorted by plain JavaScript string comparison, which is case-sensitive.
- Selected cameras are OR-ed. The match is exact and case-sensitive.
Font (observed)
The font rule is not a family picker. It has two choices, activated and deactivated, which test whether Eagle has installed the font:
- When a font is activated, Eagle copies it into
~/Library/Fonts/EagleApp/on macOS. At startup it lists that folder and records<base name>_<.ext lower-cased>for each file. - An item’s key is
<postScriptName>_.<ext>. ThepostScriptNameis the first language value infontMetas.postScriptName. - Activated passes items whose key is in the set, and deactivated passes those whose key isn’t. Items without
fontMetasfail both.
How falcon does it
Filter has imported, created and modified (DateFilter), duration (a pair of optional bounds in seconds), bpm (a pair of optional bounds, read from and written to saved filters; search::bpm(item) reads the value), cameras (a set) and font (FontFilter::Off, Activated(keys) or Deactivated(keys)).
- A
DateFiltertakes an optionalClock(nowand the start of today). With none, it reads the system clock and time zone.local_day_startbuilds range ends from a date picker. DurationUnit::to_secondsconverts the min and max fields.- These helpers build the panel’s options:
distinct_cameras,month_counts,duration_range,installed_font_keys_inwithfont_dirs(Eagle’sEagleApp/and falcon’sFalcon/under~/Library/Fonts, the second a deliberate difference), anddistinct_font_families(fontMetas.fontFamily, a falcon extra).
The Color filter’s menu opens with a picker: a square where the horizontal position sets saturation and the vertical position sets brightness for the current hue, and a vertical strip beside it that sets the hue. Dragging either one changes the filter colour as it moves, and the hue is kept when the brightness reaches black. Below it are the swatches, then the hex field with a small square showing the current colour, the accuracy slider, the Grayscale only box and Clear.
The swatches are exposed to assistive technology as radio buttons named after their colours (Red, Orange, …, Moss), with their selected state, and a press on one picks the colour as a click does. The picker’s square and strip are named too. The folder colour rows, the inspector’s palette swatches (named by hex) and the welcome flow’s themes are exposed the same way.
A popup takes every pointer click inside it: egui hit-tests by layer, so a click on a swatch or in the gaps between swatches never reaches the grid underneath. AccessKit’s own hit test walks its tree instead, last child first, and egui builds that tree in creation order, so on its own it finds a card drawn later in the frame before the popup above it. crates/falcon-app/src/access_order.rs sorts each tree update by layer order before it leaves the app, and gives a menu or dialog its bounds so a press between its controls stops there. Two windows in the same layer order keep their creation order, as egui does not publish their stacking.
Colour search (observed)
Eagle compares the query colour with the item’s two dominant palette colours, then with any colour marked as the item’s primary colour.
- Items with no palette, or whose first colour covers less than 25%, never match.
- An exact RGB match with either of the two colours matches at once.
- Otherwise both colours are converted to CIELAB. A colour matches when its CIEDE2000 difference is below the accuracy setting (default 20, range 5–40) and its CIE76 difference is below the accuracy plus 50. Each dominant colour is only tested if it covers more than 33%.
- Failing those, a palette colour marked as primary (
marked: true, set with the inspector palette’s Primary Colour toggle) matches when its CIEDE2000 difference is below 30, whatever its share of the image and whatever the accuracy setting. Its CIE76 distance is the sort key. Rule 1 still applies first.
Smart folder colour rules don’t look at the primary colour, and have no white-background branch; see the Smart folders chapter.
Matching items are sorted by CIE76 distance, closest first.
Falcon’s CIEDE2000 implementation is checked against the published reference values from Sharma et al. One of Eagle’s branches, for mostly white backgrounds, can never run as the code is written. Falcon nests it inside the dominant-colour test, which is what it appears to be meant for. That is inferred.
Name ranking (observed)
Eagle’s tag and folder filter searches, and Go to Folder, rank names with a quick-search score (string_score). It is 1 for identical strings and 0 when the letters of the keyword don’t all appear in the name in order. Otherwise it rewards letters that follow each other, letters that start a word and letters whose case matches, plus a bonus for a shared first letter. Lengths and positions count UTF-16 units, as in JavaScript.
rank_names applies it as Eagle does:
- Spaces are dropped from the keyword.
- Both sides are decomposed (NFKD), stripped of combining accents and lower-cased, so
efindsé. - Names that score 0 are dropped, and the rest are ordered best first, with ties keeping their order.
Eagle also scores the pinyin of Chinese names and matches traditional against simplified characters. falcon implements neither.
Sorting (observed)
| Key | Natural direction |
|---|---|
| Date Imported (the default), Created, Modified | newest first |
| Title, Extension, Tags | A–Z, with numbers compared by value (img2 before img10) |
| Size, Dimensions, Rating | smallest first |
| Duration | shortest first, by whole seconds (parseInt), with a missing duration as 0 |
| Random | shuffled once per visit |
Eagle’s sortIncrease flag reverses this direction when it is off. The Trash is always sorted by deletion time, newest first.
Saved filters
A saved filter stores the filter’s rules and the search text in saved-filters.json (see Library format). Applying one replaces the whole filter and the search, and refills the fields of the filter menus. The rules falcon reads and writes include:
- comments, under
annotation(has, none or keywords) - the ratio shapes
43,34,169and916, and the custom ratio ascustomwithwidthandheight - excluded Uncategorized, as
folder.excludes.NoFolders, next to the excluded folder ids - tags, folders, type, size, dimensions, rating, notes, URL, colour, dates, duration, BPM, camera and font
- Eagle’s AI searches,
imageandsemantic, which AI Search applies; until it can, they are kept and the filter bar says why (see the App chapter)
Go to Folder
⌘J (Find ▸ Go to Folder…) and a bare J open a switcher near the top of the window. Pressing the key again closes it, and reopening it keeps the last keyword, selected. The code is in crates/falcon-app/src/quick_switch.rs and ui/dialogs/quick_switch.rs.
- Tabs: Folders, Tags, Smart Folders and Items. Tab moves to the next tab and ⇧Tab to the previous one, both wrapping.
- No keyword: the Folders and Smart Folders tabs list the whole tree in order, indented by depth, with the parent’s name muted at the right. The Tags tab lists tags with grouped ones first, then the others, each in plain string order. The Items tab lists every item that is not in the Trash.
- Recents: when there are more than 15 folders (more than 10 smart folders) and some history, up to 10 recently opened ones that still exist come first. They are listed again in the tree below, so they show twice.
- With a keyword: folders, smart folders and tags are ranked by name (see Name ranking) and shown flat. Items are searched with the same search text and query syntax as the search box, with no typo tolerance.
- Keys: ↑ and ↓ move the highlight, Return opens the highlighted row and closes the switcher, Esc closes it, and a click opens a row. A folder or smart folder opens as a view; a tag opens its page; an item opens the first of its folders that exists (else All) with the item selected and scrolled into view.
- History: opening a folder or a smart folder moves it to the front of its list, which keeps the latest 200 and is remembered across launches with the other search settings, shared by every library.
Smart folders
Smart folders live in the library metadata.json as smartFolders. Falcon keeps each entry as raw JSON so it round-trips unchanged, and evaluates it through falcon_library::smart.
Shape (observed in code)
The Assets fixture has no smart folders, so these shapes come from Eagle’s code, not from a real file.
Eagle’s save rebuilds each top-level entry with these keys, in this order. Unset keys are dropped:
| Key | Type | Notes |
|---|---|---|
id | string | Same generator as item IDs |
icon | string | Optional. A name from the folder icon picker (falcon_library::model::FOLDER_ICONS) |
iconColor | string | Optional. red, orange, yellow, green, aqua, blue, purple or pink. No key means the default grey |
name | string | NFC-normalised on save. Renames are cut to 1024 characters |
description | string | "" when empty |
modificationTime | integer | ms. Set on creation and when the edit dialog saves. Icon, colour, sort and move edits leave it alone |
conditions | array | See below |
children | array | Written when present. The dialog always creates [] |
orderBy, sortIncrease | string, bool | Optional. The smart folder’s own sort, with the same keys as folders |
{ "id": "…", "icon": "star", "iconColor": "pink", "name": "Red logos", "description": "",
"modificationTime": 1793000000000,
"conditions": [ { "rules": [ { "property": "type", "method": "equal", "value": "png" } ],
"match": "AND", "boolean": "TRUE" } ],
"children": [] }
Nested entries aren’t rebuilt. Eagle writes them as they sit in memory, minus newFolderName, editable and $$hashKey. A child made in the dialog is {id, name, conditions, modificationTime, children, parent}. parent is the parent smart folder’s id, and keys set later (icon, iconColor, orderBy, …) follow in the order they were added. Eagle may also write runtime keys such as imageCount or pinyin into nested entries. Falcon doesn’t add those (inferred to be harmless, since Eagle recomputes them on load).
Editing (observed)
- Create: the dialog appends a new smart folder to the top level or to the chosen parent’s children.
- Edit: the dialog replaces
nameandconditionsand stampsmodificationTime. It edits the stored conditions in place, so keys it doesn’t touch stay. On opening it addsboolean: "TRUE"to any condition without one. - Icon and colour: these are separate menu commands. Picking the blank icon or no colour deletes the key.
- Drag onto a smart folder: appends the dragged folders to its children and sets their
parent. Dropping a folder onto one of its own descendants is refused. - Drag between siblings: inserts the folder before or after the target in the target’s list. Eagle tries to delete
parentwhen the target is top-level, but the delete hits the array rather than the folder. The stale key only lingers on nested folders: top-level entries are rebuilt without it on save. - Delete: removes the folder with its children. Every edit rewrites
metadata.jsonwith a fresh top-levelmodificationTime.
Falcon’s API, all on Library:
| Call | Effect |
|---|---|
create_smart_folder(name, conditions) | Appends a top-level entry in the rebuilt shape above |
create_smart_folder_in(parent, name, conditions) | Same, or appends a nested entry in the dialog shape with parent |
create_smart_folder_group(parent, name) | Appends a smart folder group (see below) |
update_smart_folder(id, name, conditions) | Edits at any depth and stamps modificationTime |
rename_smart_folder(id, name) | Changes only name (cut to 1024 characters), at any depth, as Eagle’s sidebar rename does: modificationTime is left alone |
move_smart_folder(id, parent, index) | Moves to a position among the new siblings. Sets parent, or removes it at the top level. Refuses cycles |
set_smart_folder_icon(id, icon, color) | None removes a key. New keys go right after id on top-level entries, as Eagle’s save places them, and at the end of nested ones |
set_smart_folder_order(id, order, increase) | Sets or clears orderBy and sortIncrease |
delete_smart_folder(id) | Removes the folder with its children |
set_smart_folder_description(id, text) | Sets description at any depth: an existing key keeps its place, a missing one goes right after name; modificationTime is left alone |
Library::smart_folders() returns SmartFolder values with icon, icon_color, description, order_by and sort_increase read out for display.
The dialog (observed)
Eagle’s dialog lists every condition. Each has a row choosing all (AND) or any (OR) of its rules and whether they must be true or false (boolean), then its rules, one per row: property, method, value. The second and later conditions are labelled as AND-ed with the ones above. A new condition is {"rules": [...], "match": "OR", "boolean": "TRUE"} with one rule, name contains "". A new rule is {"property", "method", "value"}; unit follows for file size and duration, and width and height for a custom shape. Changing a rule’s property resets its method and value. Eagle caps both lists at 30, won’t remove a condition’s last rule, and saves an empty text value as it is (such a rule never matches). Picked dates are stored as local midnight.
Falcon’s dialog (smart_dialog_ui, with the rows in organize_ui::smart_conditions_ui) does the same, in its own words: each condition reads “Items must match / must not match all / any of these rules”, with buttons to add and remove conditions and rules. It offers every property the evaluator knows (table above). Dates are typed as YYYY-MM-DD, or picked from a calendar that opens from the button beside each date field (it starts at the date in the field, else today, and a click on a day fills the field), within takes a day count, folders come from a checklist of the library’s folders, tags are comma-separated, and colours have a swatch picker. A value that can’t be stored (a missing number, a bad date or colour) shows a message and disables Save, where Eagle would store whatever the field held. Falcon also needs at least one condition, so the dialog can’t turn a smart folder into a group. Under the rules, at the left of the buttons, a live count says how many items the rules would show (“12 items found”), counting the smart folder’s ancestors’ conditions too, as Eagle’s dialog does. It counts on a worker thread (smart_count.rs, smart::count_draft) once the rules have stayed unchanged for 250 ms, over a snapshot of the library taken when the dialog first counts; while a newer count is pending the old number is dimmed, and rules that can’t be saved show no count.
Saving never loses rules. The draft lives in falcon_library::smart_draft, which keeps the stored JSON next to what the dialog shows:
- Untouched conditions and rules are written back exactly as they were read.
- Edited ones are the stored object with only the changed keys replaced. Key order and keys falcon doesn’t know survive. Unlike Eagle, falcon doesn’t add
booleanto a condition that lacks it unless you invert the condition. - Unsupported rules: a rule with a property falcon doesn’t know, a method that property doesn’t have, a unit other than the ones listed, or a value of the wrong JSON type shows as a read-only “Unsupported rule” row with its JSON. It is saved verbatim until you remove it.
- Date rules keep their stored timestamp when only the method changes between
on,beforeandafter. Switching to or fromwithinrestarts the value (30 days), as in Eagle.
Smart folder groups (observed in code)
A smart folder group has no flag of its own. Eagle’s “new smart folder group” command (in the + menu next to the smart folder heading) appends a top-level entry with conditions: [] and the grid icon, named “Untitled” in the UI language. It saves at once, then opens the group and starts a rename. The save writes it as {"id", "icon": "grid", "name", "description": "", "modificationTime", "conditions": [], "children": []}. The command only makes top-level groups, but a group can be dragged into another smart folder like any other entry.
Any entry without conditions behaves as a group:
- View: it shows the union of its children’s items. With no children it shows nothing.
- Badge: it shows no count (Eagle’s counter returns 0 for an entry without conditions).
- Children: each child still has to pass its own ancestors’ conditions. A group adds none.
SmartFolder::is_group() is conditions.is_empty(). create_smart_folder_group(None, name) writes the top-level shape above. create_smart_folder_group(Some(parent), name) nests a group in the key order of Eagle’s new-group object, with parent added: id, name, modificationTime, children, conditions, icon, parent (inferred from how Eagle writes nested entries as they sit in memory).
How falcon differs: Eagle’s union checks each child with its plain rule test, which treats a child with no conditions as matching every item. So a group nested in a group would show the whole library in its parent’s view, but only its own children’s items when opened. Falcon uses the second reading in both places: a nested group is the union of its own children.
Evaluation (observed)
- Conditions: all of them must pass.
boolean: "FALSE"negates one. - Rules in a condition: combined by
match.ANDstops at the first rule that fails andORat the first that passes; any other value needs every rule to pass. A condition with an emptyruleslist passes. - Errors: Eagle’s whole test fails, negated condition or not, when it reaches a rule whose property it doesn’t know, or a condition without
rules. Falcon does the same. - Nesting: a child must also pass every one of its ancestors’ conditions, so it narrows its parent. Moving a child out of its parent stops the narrowing.
- No conditions: a smart folder without conditions is a group and shows the union of its children (see below).
Rules by property. Number rules take value: [a, b], with b used only by between. Date rules take ms timestamps, except within, whose a counts days.
| Property | Methods | Notes |
|---|---|---|
name, url, annotation (note), folderName | equal, contain, uncontain, startWith, endWith, regex, empty, not-empty | Text and value are lower-cased. An empty value fails every method but empty and not-empty. folderName passes when any of the item’s folders passes; an item in no folder fails |
camera | the text methods | rawMetas.camera, lower-cased. A photo without a camera fails every method, empty included |
comments | the text methods | empty and not-empty look at the comments list. The others test every comment’s annotation joined together, without lower-casing and without refusing an empty value. An item without the key fails them |
width, height, bpm | =, >=, <=, >, <, between | An item without bpm never matches |
fileSize | the number methods | unit kb divides by 1024, anything else by 1024² |
duration | the number methods | duration in seconds; unit h or m converts it to hours or minutes, anything else keeps seconds. No duration never matches |
iso | the number methods | Integer parse (JavaScript parseInt) of rawMetas.isoSpeed and of the value |
aperture, focalLength | the number methods | The first number in rawMetas.aperture (f/2.8) or rawMetas.focalLength (50.0 mm) |
shutter | the number methods | The second number in rawMetas.shutter: 250 for 1/250.0 sec. A whole-second exposure (2.0 sec) has no second number and never matches |
createTime (date added), btime (created), mtime (modified) | on, before, after, between, within | btime and mtime fall back to modificationTime. before and the end of between include the following 24 hours. on compares local calendar days |
timestamp (date taken) | the date methods | Integer parse of rawMetas.timestamp |
tags, folders | union, intersection, equal, identity, empty, not-empty | Folder ids for folders |
type | equal, unequal | An extension or a type group |
rating | equal, unequal, contain | No star, 0 and "none" all mean unrated. contain takes the stars as text ("35"); any none in it matches every item |
shape | equal, unequal | custom compares the item’s ratio with the rule’s width and height |
color | similar, accuracy, grayscale | A stricter colour test than the filter panel’s: the first colour must cover at least 33% and be within 96 of the query on each RGB channel; then an exact match with either dominant colour, or a CIEDE2000 difference under 20 (similar) or 10 (accuracy) and a CIE76 difference under that plus 50, for each of the two dominant colours that covers more than 33%. The primary (marked) colour and the filter’s white-background fallback don’t count, as in Eagle |
fontActivated | activate, deactivate | Whether the font is activated through Eagle, by the key <postScriptName>_.<ext> (see Font). An item without fontMetas fails both |
Eagle’s quirks are kept:
- File size units: any unit other than
kbis treated as MB. - Start and end matches:
startWithandendWithbuild a case-insensitive regex from^plus the value and the value plus$, without escaping, so the value is a pattern there too. - Lower-cased patterns: for the lower-cased properties the regex pattern is lower-cased with the text, so
\Dacts as\d.
How falcon differs
- Regex engine: Eagle runs JavaScript
RegExp; falcon runs the Rustfancy-regexcrate, which has look-around and backreferences as JavaScript does.\w,\d,\band case folding are Unicode-aware, where JavaScript without theuflag is ASCII-only, and a pattern whose backtracking runs past falcon’s limit on a text fails on that text. An invalidregexpattern fails the rule in both, but Eagle fails its whole test on an invalidstartWithorendWithpattern, where falcon fails only that rule. - Installed fonts: like Eagle, falcon counts a font as installed when a file named after it is in Eagle’s font folder (
~/Library/Fonts/EagleApp, read once per evaluation). Unlike Eagle, it also counts a font in falcon’s own folder (~/Library/Fonts/Falcon), where falcon’s font viewer activates fonts, so a font activated by falcon is installed for falcon’sfontActivatedrule and not for Eagle’s. Fonts installed any other way count as not installed in both. There are no such folders on other systems, so there every font is “not installed”. - The dialog: see above for how falcon’s differs from Eagle’s.
HTTP API
Eagle serves a local JSON API on port 41595 while a library is open. Extensions, scripts and MCP servers use it. Falcon serves the same API from falcon-server, both inside the desktop app and through falcon serve <library>.
Envelope (observed)
| Outcome | HTTP status | Body |
|---|---|---|
| success | 200 | {"status":"success","data":…}, or no data at all |
| string error (v1) | 500 | {"status":"error","data":"File does not exist."} |
| thrown error (v2) | 500 | {"status":"error","message":"id is required","data":{}} |
| unknown path | 404 | "message":"method not allowed" (Eagle’s own wording) |
| wrong method | 405 | "message":"method not allowed" |
A request is served when its Host header is localhost:<port>, 127.0.0.1:<port> or 0.0.0.0:<port>, or when it carries the Developer token as token: in the query string for GET, in the JSON body or the query string for POST. With no token set, which is the default, only the Host check applies, and no token value (not even an empty one) lets a request from another Host in. With a token set, the value must match exactly. A token that is only whitespace counts as no token. Anything else gets 401. Eagle does the same (observed in json-rest-light), with its port hard-coded to 41595. Eagle’s /item, /folder, /smart-folder and playground handlers skip the check entirely; falcon applies it to them too, on purpose.
v1 handlers reject with a string, which lands in data. v2 handlers throw an Error; Eagle’s JSON server puts its text in message and serialises the error object itself as data, which comes out as {} (observed in the bundled json-rest-light module). A v2 handler that returns nothing gives {"status":"success"} with no data, and false gives "data":false. Each route keeps Eagle’s choice between throwing, false and nothing.
Routes
- v1:
application/info(also served at/)library/info,history,switchandiconfolder/list,listRecent,create,rename,updateandunlockpreferences/collect/onandofftag/all,list,listRecentandgroupsitem/addFromPath(s),addFromURL(s),update,info,thumbnail,setCustomThumbnail,refreshPalette,refreshThumbnail,moveToTrashandlist
- Links:
/item,/folderand/smart-folderredirect toeagle://links (see “Link redirects”). - Playground:
GET /api/v2/playground(see “The playground”). - v2: every route in the table below.
v2 lists are paginated as {data, total, offset, limit}. limit defaults to 50 (also for 0 or junk) and is capped at 1000; offset defaults to 0.
Eagle’s quirks are kept:
- Paging: in
item/list,offsetis a page number, and the page is re-sorted newest first after it is cut. - Ratings: v1
item/updateignores a rating of 0; v2item/updateaccepts 0 to 5.
More v1 routes
| Route | Method | Parameters | Reply | Errors |
|---|---|---|---|---|
application/info, / | GET | {version, prereleaseVersion, buildVersion, showCollectModal, platform, preferences}. showCollectModal is a boolean, true when preferences.general.showCollectModal is "true". preferences is the app’s Eagle-shaped subset; falcon serve reports {"general":{"showCollectModal":"false"},"developer":{"apiToken":""}} | ||
folder/unlock | POST | folderId, password | success without data | Folder does not exist., Password is incorrect. |
preferences/collect/on, off | GET | success without data; sets general.showCollectModal to "true" or "false" | preferences file not exist. | |
item/setCustomThumbnail | POST | id, thumbnailPath | true | File does not exist., Failed to set custom thumbnail. |
item/refreshPalette | POST | id | success without data | File does not exist. |
item/refreshThumbnail | POST | id | success without data | File does not exist. |
folder/unlock lasts for the server’s session only. Until it ends, folder/list and folder/listRecent report that folder with isUnLock: true and its children. Nothing is written to the library.
Link redirects
/item?id=<id>, /folder?id=<id> and /smart-folder?id=<id> answer 302 with a Location of eagle://item/<id>, eagle://folder/<id> or eagle://smart-folder/<id>. An unknown id answers 404 with {"error":"File does not exist."}, {"error":"Folder does not exist."} or {"error":"Smart Folder does not exist."}. These paths go through the same auth check as the rest.
The playground
GET /api/v2/playground serves falcon’s own page, not a copy of Eagle’s. It lists the routes, has a form for the parameters of each, a field for the token, and shows the equivalent curl line.
eagle:// links
The app bundle registers the eagle scheme (CFBundleURLTypes in scripts/bundle.sh). The app handles eagle://item/<id>, eagle://folder/<id> and eagle://smart-folder/<id>, both from clicked links and as launch arguments. A clicked link reaches the running app as an Apple “get URL” event, which falcon catches before the event loop starts. An item opens in the viewer inside its first folder, or All when it has none; a folder or smart folder is shown. Ids the shown library doesn’t have are ignored, and links wait while the app is locked. eagle://openFolder/<id>, the form Eagle’s “added to folder” notices link with, opens the folder in the app as a folder link does (Eagle sends it to the same handler). Plugin links are not handled.
The bundle also registers image, video, design, .eaglepack and .library document types, so Finder’s Open With and Dock drops reach falcon. Files are imported into the shown library, a package is imported as a package, and a library is opened. Folders can be dropped on the Dock icon, but falcon does not become any folder’s default app.
The files reach the running app as an Apple “open documents” event, handled in crates/falcon-app/src/open_files.rs and passed to the same import path as a drop (App::import), as Eagle’s open-file handler imports files and opens packages. AppKit installs its own handler for that event while the app finishes launching, so falcon installs its handler when AppKit posts NSApplicationWillFinishLaunchingNotification, which is still before a launch’s own files arrive. A package asks first, showing its size, as Eagle does. With no library open, files other than a library are ignored.
v2 routes (observed)
Read from Eagle 4.0’s app/js/api-server-v2.js (the route table), api-v2-playground-config.js (parameters) and the handlers in app/js/plugin/index.js and plugin/handlers/smart-folder-handlers.js. “Paged” means the reply is wrapped as above. GET takes the query string, POST a JSON body, so array parameters only work through POST.
| Route | Method | Parameters | Reply | Errors and empty replies |
|---|---|---|---|---|
app/info | GET | {version, prereleaseVersion, buildVersion, platform} | ||
library/info | GET | library metadata.json plus top-level path and name (v1 nests them under library) | ||
library/history | GET | offset, limit | paged list of library paths | |
library/switch | POST | libraryPath | true | Library does not exist. |
library/icon | GET | libraryPath | icon.png as Node prints a Buffer: {"type":"Buffer","data":[…bytes]} | Library does not exist., Library icon does not exist. |
item/get | GET, POST | id, ids, keywords, tags, folders, smartFolders, ext, annotation, url, rating, shape, isUnfiled, isUntagged, isSelected, fields, offset, limit | paged items | smart folder not found: <id> |
item/query | POST | query, offset, limit | paged items | |
item/countAll | GET | number of items, trash included | ||
item/add | POST | one of path, url, base64, bookmarkURL; name, website, tags, annotation, folders; or items (up to 1000 such entries, which may also carry star and modificationTime) | {id}, or {ids} for items | item.add requires at least one of: url, path, base64, bookmarkURL, Batch limit exceeded: max 1000 items per request, got <n>, not support for directory |
item/update | POST | id; any of name, annotation, url, tags, folders, star (0–5), ext, width, height, noThumbnail, noPreview, isDeleted, modificationTime | the item | false for an unknown item; name too long (max 10000), annotation too long (max 10000) |
item/getComments | GET | id | the item’s comments array, [] when none (not paged) | id is required, item not found: <id> |
item/addComment | POST | id, annotation, and either x, y, width, height (rounded) or duration | the comment: id, annotation, lastModified, then the region or duration | cannot have both duration and x/y/width/height, must provide either duration (video) or x/y/width/height (image), duration must be >= 0, x, y, width, height must all be numbers, width and height must be > 0, annotation exceeds max length (10000) |
item/updateComment | POST | id, commentId, then the same fields as add | the comment, with lastModified set to now | commentId is required, no comments found, comment not found: <id>, cannot set duration on an image comment, cannot set x/y/width/height on a video comment, width must be > 0, height must be > 0 |
item/removeComment | POST | id, commentId | true | as update |
item/setCustomThumbnail | POST | itemId, filePath | true, or false when the item is unknown or the file can’t be used | never throws |
item/refreshThumbnail | POST | itemId | true for a known item, false otherwise | never throws |
folder/get | GET, POST | id, ids, isRecent, isSelected, offset, limit | paged folders: the top-level tree with no filter, a flat list with one | |
folder/create | POST | name, description, parent, iconColor | the folder | false without a name; an unknown parent puts it at the root; name too long (max 10000) |
folder/update | POST | id, name, description, tags, iconColor, parent | the folder | nothing for an unknown folder; name too long …, description too long … |
smartFolder/get | GET, POST | id, ids (POST), offset, limit | paged: the whole tree with imageCount on each entry, or a flat list for id/ids | |
smartFolder/create | POST | name, conditions, description, icon, iconColor, parent | {id, name, conditions, description, icon, iconColor, children, modificationTime, imageCount} | name is required, name too long (max 10000), conditions is required, the rule errors below, description too long (max 10000), parent smart folder not found: <id> |
smartFolder/update | POST | id, name, conditions, description, icon, iconColor | the entry with imageCount; modificationTime always stamped | id is required, smart folder not found: <id>, name cannot be empty, rule errors |
smartFolder/remove | POST | id | true; children and their Quick Access pins go too | id is required, smart folder not found: <id> |
smartFolder/getItems | GET, POST | smartFolderId, orderBy, fields, offset, limit | paged live items; fields keeps only those keys, in the order asked | smartFolderId is required, smart folder not found: <id> |
smartFolder/getRules | GET | the rule schema: per property its category, methods, valueType, and when present noValueMethods, options, extraFields | ||
tag/get | GET, POST | name (case-insensitive substring), offset, limit | paged tags | |
tag/getRecentTags, getStarredTags | GET | offset, limit | paged tags from historyTags / starredTags that are still in use | |
tag/update | POST | originalName, name | the renamed tag | nothing for a missing or unknown originalName; name too long (max 10000) |
tag/merge | POST | source, target | {affectedItems, sourceRemoved: true} | tag.merge requires "source" and "target", tag.merge: source and target must be different; nothing when either tag is unknown |
tagGroup/get | GET | offset, limit | paged groups | |
tagGroup/create | POST | name, tags, color, description | the group (id, name, tags, then color and description when given) | false without a name; name too long (max 10000) |
tagGroup/update | POST | id, name, tags (replaces), color, description | the group | nothing without id or for an unknown group |
tagGroup/remove | POST | id | true; the tags stay on their items | false for an unknown group |
tagGroup/addTags | POST | groupId, tags, removeFromSource | the group | tagGroup.addTags requires "tags" array, tags array too large (max 1000); nothing for an unknown group |
tagGroup/removeTags | POST | groupId, tags | the group | tagGroup.removeTags requires "tags" array, tags array too large (max 1000); nothing for an unknown group |
tagGroup/addTags keeps a tag in its other groups unless removeFromSource is set, in which case it moves.
Smart-folder conditions are checked strictly before anything is saved. The first problem is reported, in Eagle’s words: conditions must be a non-empty array, max 30 conditions, each condition must have non-empty rules array, max 30 rules per condition, match must be "AND" or "OR", boolean must be "TRUE" or "FALSE", then per rule invalid property: …, invalid method '…' for property '…'. Valid methods: …, value is required …, a type message for the value, and the unit or custom-shape width/height requirements.
Updates: Eagle 4.0 has no change feed, cursor or “changed since” route in either API version. The update routes are item/update, folder/update, tag/update, tagGroup/update and smartFolder/update, all served. A client that wants to notice changes has to poll, for example library/info‘s modificationTime (bumped on every library metadata.json write) or the items’ lastModified (inferred; Eagle documents no polling scheme).
Folder update quirk (observed): folder/update compares iconColor and parent with the folder’s current values even when they are left out. A request without iconColor clears the colour, and one without parent moves a subfolder to the root. Falcon does the same, so clients should always send both. A move into the folder itself or its own subtree is skipped silently.
Library switching
library/switch (v1 and v2) opens the given path into the server’s library slot, which the desktop app shares, so the app shows the new library too (see “The desktop app” below). Falcon refuses a path that does not exist (Library does not exist.), and also one that is not a folder ending in .library with a metadata.json (Not an Eagle library., falcon’s own text: Eagle tries to open anything that exists). Opening only reads, so nothing is created or written in the target.
library/history comes from Eagle’s app settings (libraryHistory). In the desktop app, the app hands its recent libraries list to the server with Handle::set_history when the server starts, when the shown library changes and when preferences are applied. falcon serve has no such list, so it reports the libraries it has served since it started, newest first, beginning with the one it was started on. Libraries handed over with set_library count as served.
The desktop app
Eagle has one library open per window, and its API serves that library; library/switch goes through the same code as the library panel (observed in js/api-server.js, which calls the window’s openLibrary). Falcon’s app can keep several libraries open (see Several libraries), and the API follows the one being shown:
- The running server keeps its port. When the shown library changes, the app calls
Handle::set_library(Shared), which swaps the library the next request sees and adds it tolibrary/history.Handle::library()returns the one being served.startandserveare unchanged. library/switchstill replaces the library inside the served slot. The app checks the slot’s root every frame. When it changed, the app puts the library that was there back into the slot (reopened from disk; the server’s drop of it has already written anything pending) and shows the new path in a workspace of its own: an existing one if that library is already open, otherwise a new one with fresh state and no undo history. While tasks run in the shown library the app asks first, with Force Switch and Cancel; on Cancel the served library stays the shown one, although the request already answeredtrue. The path also joins the app’s recent list. If the old library can’t be reopened, the shown workspace becomes the new library and starts over.Handle::set_history(paths)replaces the listlibrary/historyreports, newest first, dropping duplicates.Handle::set_token(token)sets the Developer token; an empty token (or one that is only whitespace) turns the token check off: only the Host check applies, as in Eagle.Handle::set_preferences(value)sets the preferencesapplication/inforeports.Handle::take_preferences()returns them when a request changed them since the last call (preferences/collect/onandoff), otherwiseNone. The app callsset_token,set_preferencesandset_historytogether, and pollstake_preferenceseach frame. It keepsshowCollectModalchanges made over the API; Preferences has no checkbox for it yet.
Developer token
The Developer pane in Preferences holds the API token (Prefs::api_token). It is empty by default, as Eagle’s developer.apiToken is (observed); falcon doesn’t make one on first launch. A token made by an earlier falcon version is kept. While it is empty the pane says “No token. Only requests addressed to localhost are served.” and offers a Generate button, which makes a random UUID, Eagle’s format. With a token set the pane shows it with Copy, which puts it on the clipboard, Regenerate, which makes a new one, and a link of the form http://localhost:<first port>/?token=…. Changes apply on Save or Apply.
The server rule is tested in falcon-server (a unit test of the rule and an end-to-end test), and the default and the keeping of an earlier token in the preferences tests.
How falcon differs, on purpose
- Local only: falcon listens on 127.0.0.1 only.
- Library routes and AI Search only: routes that aren’t about library data (plugins, script injection, window control) are left out, as are bookmarks and batch save.
- AI Search:
/api/v2/aiSearch/*answers from falcon’s own AI Search (see the App chapter).isInstalled,isReady,isStartingandisSyncinganswer a boolean;getSyncStatusanswersisSyncing,done,totalandpending;checkServiceHealthanswershealthy,status(notInstalled,starting,syncing,readyorerror) anderror.searchByTexttakesqueryortext,searchByBase64takesbase64orimage,searchByItemIdtakesitemIdorid, and each takeslimit. They answer the v2 list shape with ascoreon each item. Before the model is installed or ready they throw and say so, and they do under thefalconcommand line, which has no model. These shapes are falcon’s reading of the route names; Eagle’s own were not recorded. - Auth on link and playground paths: falcon checks Host or token on
/item,/folder,/smart-folderand the playground; Eagle doesn’t. - Imports:
addFromPathand v2item/addreply after the import has run, so a returned id exists. Eagle makes up the id, replies, and imports later. A failed singleitem/addtherefore throws in falcon, and a batch lists only the ids that imported. - Import locking: the add routes (
addFromPath(s),addFromURL(s), v2item/add) read, thumbnail and analyse each source before taking the library lock, and lock only briefly to look up folders and to commit, as the app’s own import does. Downloads foraddFromURL(s)also run unlocked. A slow or dataless iCloud source therefore stalls only its own request; the app, which shares the library, keeps drawing. - Bookmarks:
item/addwithbookmarkURLthrowsbookmarks are not supported. - Add routes, tags: URL routes (
addFromURL(s), and v2item/addwithurlorbase64) put the target folders’ auto-tags first, then the given tags. Path routes add no auto-tags (observed: Eagle’suploadUrlsaddsextendTags, whileuploadFilesfrom the API gets no folder). v1addFromURL(s)also move the given tags to the front of the recent tags, as Eagle’saddHistoryTagsdoes; the path routes and v2 don’t. - Duplicates: Eagle imports API items and then, with the duplicate notification on, asks in its window. Falcon imports them without asking, because API imports have no dialog yet. This holds for path and URL routes alike.
- Thumbnail routes:
item/setCustomThumbnail,refreshPaletteandrefreshThumbnailfinish their work before replying; Eagle queues the work and replies at once. Falcon does it without holding the library lock and writes back only the keys it changed, so edits made meanwhile survive.library/switchandlibrary/iconalso read without holding the lock. - Colours: folder, smart-folder and tag-group colours must be one of Eagle’s eight names; anything else throws.
""ornullmeans the default and removes the key. Eagle stores whatever it is given. - Tag rename:
tag/updatealso renames the tag in the recent and starred lists, as falcon’s own rename does. Eagle’s API leaves those lists alone. - Tag groups: names are NFC-normalised and cut to 1024 characters, and tags are de-duplicated, as in falcon’s tag manager.
- Comments: they are written in the order falcon’s model uses (
id, region orduration,annotation,lastModified). TheaddCommentreply uses Eagle’s order. - Smart folders: a top-level entry made through the API is stored in the shape Eagle’s save gives top-level entries, with no empty
iconoriconColor. A nested one is stored as the API builds it. - Library history: see above.
- Several libraries: the app can keep several libraries open; the API serves the one shown, and
library/switchopens or focuses a workspace instead of replacing the window’s only library (see above).
Ports
Eagle listens on *:41595. macOS still lets another program bind 127.0.0.1:41595, and that program would then receive every localhost request meant for Eagle. Falcon therefore checks whether a port already answers before binding it. The app always starts at 41596, and falcon serve refuses to start on a port that is already in use.
The desktop app
falcon-app is falcon’s version of Eagle’s main window, built with egui. Its look comes from Eagle itself:
- Colours: dark is sampled from Eagle’s window and its dark-theme stylesheet (
style/base/_color.scss). For example, the content area is#17191d, the panels#1f2023and the accent#3297ff. Light, lightgray, gray, blue and purple are worked out from the stylesheets, and Auto follows the macOS appearance (see Themes below). - Fonts and icons: SF Pro for text, plus Phosphor Light icons, which are MIT-licensed and close to Eagle’s line icons.
- Measurements: taken from Eagle at 1280 × 800: 240 pt panels, a 48 pt toolbar, 27 pt sidebar rows and 139 pt cards.
Opening libraries
The app and the falcon CLI open and create libraries in any folder, as Eagle does. The HTTP API’s library/switch also opens a library in any folder. The system dialogs behind Open Library…, New Library…, Merge Another Library… and Select Another Path… start in the folder that holds the shown library; with no library open, the system chooses where they start.
The app opens libraries with Library::open_or_restore (see Backups): when a library’s metadata.json can’t be read or parsed, the newest usable backup in its backup/ folder replaces it before the library loads, and a message names the backup used and says the damaged file was kept in backup/. This covers the switcher, Open Library, libraries reopened on launch, dropped libraries and the library the app reopens after an API library/switch. The HTTP API’s own library/switch still uses Library::open, so a damaged library fails there. Both refuse a library made by a newer Eagle than 4.0.0 (see Newer libraries and outside changes); the app shows an alert saying which Eagle version saved it.
Libraries chosen in the switcher or with Open Library… are read on a background thread (see Several libraries below). Libraries reopened on launch, dropped on the window, opened from Try Again or Select Another Path… and the one reopened after an API switch are read on the main thread.
The library switcher (the library name at the top of the sidebar, or ⌘L) lists the open libraries, then the recent ones that are not open. Each row shows the library’s icon.png at its left, or a folder glyph when there is none; the sidebar’s library row shows the same icon. Right-clicking a row, or the ⋯ button on it, opens a menu with Change Icon… (pick a jpg, jpeg, png, webp or gif; it is scaled to 64 px on its shorter side and saved as the library’s icon.png), Remove Icon (only when there is one; asks Remove or Cancel), and Show in Finder, and Remove from List, which takes the library out of the recent history (an open library stays open). Move to Trash… (a falcon addition; Eagle has no such command) moves the library folder to the macOS Trash. Falcon never deletes a library folder outright. It is shown for every library whose folder exists, and disabled with a hint while the library is open. A dialog names the library, shows its full path and size (counted on a background thread without following links or reading file contents, so iCloud placeholders are not downloaded; Move to Trash waits for it, and a failed count shows Size unknown), and says everything in the folder goes to the Trash and can be put back from Finder. Move to Trash is red; Enter does not confirm and Esc cancels. The rule (library_removal.rs) refuses unless the folder is a real directory, neither a link nor reached through one, named *.library with a metadata.json, and is not open, being opened, inside an open library or holding one (compared by path and by device and inode). It runs again right before the Trash call. If the Trash call fails an error toast shows and nothing is deleted or changed. Afterwards the library leaves the recent list and its remembered layout and icon, and a toast names it; backups elsewhere are untouched. Under the Recent section, Clear History… asks for confirmation in a Clear Library History dialog (Clear All or Cancel) and then keeps only the libraries that are open. Recent libraries are kept across launches even when their folder has gone: such a row is dimmed with a warning glyph and a tooltip saying the folder can’t be found, and its menu offers only Remove from List (Show in Finder is disabled). Clicking it tries to open it like any other library, which brings up the missing-library screen described under Several libraries.
Add to Other Library, in the item menu (one item or the selection), the menu of a folder (sidebar or folder card), of a smart folder and of a tag group heading, lists the recent libraries other than the shown one, then Choose Library… for any other. Several items ask first. The copy runs behind the progress modal with Cancel, and a notice says how many items were added and how many the other library already had. The rules are in Adding to another library. When the other library is also open in falcon, the items appear in its window at once.
Convert WebP, in the item menu when the item is a WebP, converts every still WebP in the selection to PNG or JPG (quality 100), after a warning that the files will grow, as Eagle’s does. It runs behind the progress modal with Cancel. The item keeps its name, tags and everything else and takes the new extension and size; its thumbnail stays, except that an image no larger than 960 px on both sides shows its original instead, as in Eagle. Animated WebPs are skipped. falcon writes the new file under a temporary name and swaps it in only when complete, moves the old WebP to the Trash instead of deleting it (the way to get it back, since the step can’t be undone with ⌘Z), and lays transparency on white for JPG (falcon_library::webp_convert).
Clear Invalid Content…, in the library switcher (Eagle hides it behind a modifier-click on its library button), scans the shown library’s item folders on a background thread. An item whose original file is missing is moved to the Trash (an item whose original exists is never treated as invalid); a folder in images/ that belongs to no item and has no metadata.json is moved to the macOS Trash (Eagle deletes it); an item whose folder has gone is dropped. Nothing found says how many folders were looked at. Otherwise falcon says what it found and warns that a cloud sync must have finished, then cleans up on Clean Up and reloads the library, as Eagle does. Folders with a damaged metadata.json are left alone (falcon_library::repair).
Falcon refuses to create, open or add to a library inside the folder it is installed in (its .app bundle, or the folder holding a bare executable), as Eagle refuses ones in its install folder: removing or updating the app would delete them.
Below the library rows the switcher lists Open Library…, New Library…, Merge Another Library…, Empty Cache and Reload…, Show in Finder, Import Files…, Import Folders…, Import Eagle Package… and Import Inboard Backup…, then the collect window and Preferences. The new entries are described under Several libraries.
Windows
Falcon builds and runs on 64-bit Windows, on Intel and AMD, from the same branch as the other systems. Only Windows 11 has been tried; ARM isn’t built. The installer, Falcon-x86_64-setup.exe, installs for the current user without admin rights in %LOCALAPPDATA%\Programs\Falcon, adds a Start menu entry and an uninstaller to Add or remove programs, and leaves libraries and settings alone when you uninstall. To upgrade, run a newer installer; Falcon doesn’t update itself. Falcon-x86_64-portable.zip holds the same app to unzip anywhere and run without installing: Falcon\falcon-app.exe at the top, with the FFmpeg DLLs in Falcon\lib, where Falcon looks for them after its own folder. It keeps its libraries and settings in the same places as the installed app; to remove it, delete the folder. It isn’t code-signed, so Windows SmartScreen warns on first run (More info, then Run anyway). File and message dialogs are owned by the main window (dialog_owner.rs), which Windows disables until they are answered; they block the frame that opens them, so an unowned dialog hidden behind the window would leave falcon looking frozen.
Video plays through the bundled FFmpeg (see Video playback).
Windows has the same gaps as Linux: screen capture, the tray icon, global hotkeys, the share sheet, system notifications, Touch ID, the login item, transparency effects, iCloud handling, QuickLook’s thumbnails, live web views and page screenshots, dragging items out of the window, opening files from the file manager and registering eagle:// links. Open With shows the system’s Open With window, and revealing several files shows only the first file in each folder.
Cloud AI providers can’t be used: falcon-ai keeps API keys only in the macOS Keychain and has no key store elsewhere, so a key can’t be saved. Local providers such as Ollama and LM Studio take no key and aren’t affected.
Where things are. Logs go to %LOCALAPPDATA%\Falcon\Logs, and settings and saved state to %APPDATA%\Falcon\data.
Item names on disk. An item’s name stays in metadata.json exactly as it came, but Windows can’t keep every name as a file. crates/falcon-library/src/disk_name.rs gives the name an item’s files are kept under: <, >, :, ", /, \, |, ?, * and control characters become _, trailing dots and spaces are dropped, and a device name such as CON or NUL gets a _ after it. An item named a:b is therefore kept as a_b.png, with the name in the metadata still a:b. Code builds an item’s file names through Item::original_path and Item::thumbnail_name, never from the name directly. The limit is that a library made or changed this way won’t find that file if it goes back to a Mac, which looks for a:b.png.
What it does
| Area | Features |
|---|---|
| Window | No title bar: the traffic lights sit over the sidebar, and empty bar space drags the window |
| Library menu | The open libraries (each with a close button), then recent ones, then Open, New, Show in Finder, the collect window and Preferences. Several libraries can be open at once (see Several libraries). The open libraries, layout and sort reopen on launch |
| Preferences | ⌘, or the library menu (see below) |
| Sidebar | Eagle’s fixed entries, Quick Access, a nested smart folder tree, and a folder tree with guide lines and direct-item counts. Folders and smart folders show their icon in their colour, and locked folders a lock. Emoji icons (a falcon addition; Eagle has none): the folder and smart folder menus have an Emoji submenu after Change Icon, with about 110 single-codepoint emoji (typing in the menu’s search box finds them by their name in the current language) and Remove Emoji. The emoji replaces the icon in the folder’s sidebar row, smart folder row and Quick Access row, in colour. It is kept in falcon’s own app data (folder_emoji in the saved state, by library path and folder id, so a copied library keeps its own and a moved or renamed one starts without them), not in the library, so another Mac or Eagle won’t see it, and the folder’s icon and iconColor are left as they are. Choosing an icon with Change Icon removes the emoji. Setting an emoji is not an undo step; the emoji of a deleted folder is kept, so undoing the delete brings it back. A new subfolder takes its parent’s emoji and Duplicate copies it, subfolders included. With several folders or smart folders selected, the Emoji submenu applies to all of them. The pictures are read once, on a background thread, from macOS’s Apple Color Emoji font; until they are loaded, or without that font, the normal icon shows (folder_emoji.rs, folder_emoji_tex.rs). You can filter it, create, rename, duplicate, move and delete folders, drag folders and smart folders to reorder or nest them (a drag that begins on a picked folder moves every picked one, in one undo step, and can’t end on one of them or inside one, as Eagle’s draggedFolders), and drop items onto folders or the Trash (dropping on a folder moves the items out of the open folder, and ⌥ adds them instead; see Organising below). ⌘-click and ⇧-click pick several folders, or several smart folders, to see their items together and act on them all from the context menu (see Several folders selected below). Drag its inner edge to make it wider or narrower (200 to 480 points, as is the inspector’s; Eagle stops both at 200); the widths are remembered |
| Toolbar | The panels button (see Viewer layout), back and forward, zoom slider, the Actions button (hidden on the All Tags page; see Actions), layout panel (see Layouts and display options), filter bar toggle, search with Eagle’s syntax (it applies 200 ms after the last keystroke, or at once on Return; clearing it or applying a saved filter applies at once; plus falcon’s typo tolerance, on by default; a preference turns it off for Eagle’s exact-substring results), and a caret beside the magnifier that opens the Search Scope menu (see Search and filters) |
| Filter bar | Chips for the pinned filters (Eagle’s defaults: Color, Tags, Folders, Shape, Rating, Type) and for any filter that is on, a + menu that pins the others, Clear, Lock, and saved filters. The filters are colour (presets, hex value, accuracy, grayscale), tags, folders, shape (with the ratios and a custom W:H), rating, type, size, dimensions, comment, notes, URL, date, duration, BPM, camera and font (see below and Search and filters). Opening another view clears the filter and the search unless Lock is on |
| Grid | Four layouts. Waterfall: equal columns, each card keeping its image’s shape. Justified (the default, as in Eagle): rows of equal height. Grid: square cards, the image fitted or filling the square. List: rows under a header of columns (click one to sort by it). Cards have type badges and a name and info line, each of which can be hidden (see Layouts and display options); GIF and animated WebP cards play while hovered (see GIF and animated images). Supports multi-select, drag (inside the app, or out to Finder and other apps), a context menu and a hover preview (see below) |
| Inspector | One item: preview, palette (right-click a colour to search for it; click for Find Similar Colours, Copy HEX, RGB, RGBA, HSL, HSV, HWB and CMYK, and a Primary Colour toggle that stores marked on that palette entry and draws it with a thicker ring in the primary colour), editable title, notes and URL (a URL is kept only if it is empty, a file:// address, a web address such as example.com or https://…, an existing path, or contains ://; anything else reverts and says “Not a valid link”; an open-link icon at the right of the field opens it), tags (chips with the group colour dot; the × takes a tag off, and + opens the tag picker), folders (as in Eagle, clicking a folder chip opens that folder with the item still selected and scrolled into view, and the chip’s × removes the item from the folder, one ⌘Z step; the tooltip gives the folder’s full path), comments (edit and delete; click a region comment to show it, or a timed comment to play the video from its moment), rating and properties (clicking the Type row opens a dialog to change the extension: it warns that the file is not converted, checks the text live (letters and digits, at most 10) and applies on Change or Return; ⎋ cancels; the original is renamed on disk) (with duration, BPM as stored (119.985, as Eagle’s inspector shows it), camera facts from rawMetas, and font family, style, PostScript name, version and glyph count when the item has them). Several items: a preview stack, a note and a URL field for all of them (as in Eagle there is no name field; each shows the value the items share, or is empty with “Multiple notes” or “Multiple URLs” as its hint when they differ, and a changed text goes to every item when the field loses focus, one ⌘Z step; notes are cut to 20480 characters as Eagle does, and the URL is saved without Eagle’s check), the tags they all share (× takes one off all of them, + opens the tag picker for all), a chip for each folder all of them are in (logic::shared_folders, in the first item’s order; a folder only some are in isn’t shown, a click opens the folder, the tooltip gives its path, and the × takes all of them out of it, one ⌘Z step), add all to a folder, a shared rating, trash all. Nothing selected, inside a folder: the folder’s name and description, item count, size, date imported and password state. The Password row shows a Create link when the folder has none (it opens the New password dialog), and Change and Remove links, split by a muted bar, when one is set and a single folder is shown (with several folders picked it only says Set); the links open the same dialogs as the folder’s Password menu. While the folder is locked the item count and size are left out, as in Eagle. The name saves when the field loses focus or on Return (an emptied name goes back to the saved one), the description (several lines) a second after typing stops, as Eagle’s does, or when it loses focus; each save is one ⌘Z step and changes only that key in metadata.json. Eagle saves the name a second after typing stops too; falcon waits for that field to lose focus, and also saves text left in the fields when the folder or the selection changes. Inside a smart folder the name and description are editable the same way (the name through the sidebar’s rename, the description through Library::set_smart_folder_description, which replaces an existing description where it is or puts a new one right after name, and leaves modificationTime alone, as Eagle does). Other scopes show their fixed name. Bulk actions: as in Eagle, a command on many items at once first asks “Many items at once” (“N items will be affected. Go ahead?”) with OK and Cancel; Return doesn’t confirm and Esc cancels. From 100 items (BULK_CONFIRM) it covers rating (whose notice shows only once the rating is made), moving to the Trash (the inspector button, the Delete key, the menu and the item menu), adding to a folder (Add to Last Used Folder, and dragging onto a sidebar folder, a Quick Access folder or a subfolder card; the Add to Folders picker’s closing doesn’t ask, as Eagle’s doesn’t), the note and URL fields of several items, Pin to Top and Unpin from Top, Open in Default App and Show in Finder (several files; Action::OpenFiles), and Remove from Group for tags on the All Tags page (counted in tags). Deleting folders asks from 50 items, counting the live items in the folders and all their subfolders once each, after the delete confirmation. Show in Finder shows at most the first 31 files, because each can open its own Finder window. Restoring, permanent deletion and the Duplicates page keep their own behaviour. Code that queues an action on many items calls App::push_bulk(n, action), or push_bulk_at(n, threshold, action) for another threshold. Folding: clicking a section title (Properties, Tags, Folders, Comments) folds it away to its title with a right-pointing arrow, as in Eagle; the state is shared by the three inspectors, is not per library, and is kept between launches (Folds in the saved state; state saved before has every section open) |
| Viewer | Opens between the sidebar and the inspector, with a bar holding the panels button, back, an “n / total” counter, the name, previous and next (see Viewer layout). The wheel scrolls (or zooms or pages, by preference), pinch zooms around the pointer, drag pans; a zoom slider and size menu, fit (0 or ⌘9), actual size (1 or ⌘0), fit to window edge (`), + and − (or ⌘= and ⌘−), animated GIFs, WebPs and PNGs with Eagle’s frame-by-frame GIF player (see GIF and animated images), full-resolution originals when zoomed in (see Image viewer), full-size QuickLook renders, and a cannot-preview page with an Open With button. Right-click opens the item menu; double-click leaves. Video plays with a control bar and timed comments, PDFs open in a scrolling reader with find and text selection, audio plays over its waveform and fonts show a live specimen; images take region comments, rotate, flip and crop (see below) |
| Card menu | Besides Open, Show in Finder and the folder and Trash entries: Add to Folders… (F; also the inspector’s + under Folders, for one item or several: a panel that opens beside the pointer (10 points right and 10 up, flipped, slid or lifted to stay in the window and clear of the inspector; falcon draws it as a modal with no dimming, so a click outside closes it) with a search field and three icon tabs, All, Recent and Selected (Tab cycles them; the rules are in folder_picker.rs). The folders all the items share start ticked; a click or ↩ ticks or unticks a row (it clears the search unless ⌘ is held), ↑/↓ move the highlight, ←/→ fold and unfold it. All lists the recent and ticked folders (ticked first, then newest first; five plus one per ticked folder) above the tree with the top level expanded; Recent lists those, up to twenty (right-click ▸ Remove forgets one); Selected lists the ticked folders flat in tree order and a search does not narrow it. A search is fuzzy (the letters in order, ignoring case, accents and spaces; no pinyin) and lists the matching folders flat with their parent’s name, best first: names that begin with it or have it at a word’s start or as first letters, then unbroken matches, then scattered letters, with matching recent folders above a line unless their letters are only scattered. It offers Create “…” when no folder has exactly that name: the new folder goes first on the top level, ticked. A row’s right-click menu (New Subfolder, New Folder Below) creates a folder by name. While a folder is open, “Take them out of the open folder” (remembered) sits below the list and takes the items out of it on closing. The rows are rebuilt on opening and when the tab, the search or a fold changes, not when a tick changes, so they don’t move under the pointer. There is no Save or Cancel: closing the panel (a click outside, or Esc, which applies as Eagle’s does) adds the items to the ticked folders (with their auto-tags) and takes them out of shared folders unticked, as one undo step, and does nothing if no tick changed. A footer names the keys that work for the tab, the search and the row. ⇧⌘J and Find ▸ Add to Folders… open Eagle’s older form of it instead: a centred dialog with the same list, search, Create and box, checkboxes and no tabs, where only Save applies the ticks and Cancel or Esc discards them (a folder made meanwhile stays); one undo step), Open File Location ▸ (All, then each of the item’s folders: clears the search and filters, opens the page and selects and scrolls to the item), Add to Last Used Folder (the folder items were last added to by a menu, a drop or the inspector, newest first as Eagle’s recentMoveFolders, up to 50; on Uncategorized the selection moves on), Rename or Rename N Items… (one item: the inspector’s title; several: the Rename dialog, see below), Duplicate (one item: its whole .info folder copied under a new id, metadata.json changed only in id, as Eagle’s duplicate-file), Share… (macOS: AppKit’s share sheet with the originals), Pin to Top / Unpin from Top (inside a folder: Eagle’s pinned map of folder id to time on the item; pinned items lead the folder, newest pin first, unless the folder is sorted at random; with several folders picked, each of them the item is in), Replace with File… (after a confirmation the original becomes a copy of the chosen file with its extension, written beside it and swapped in with a .bk kept until then; name, tags, folders and notes stay; thumbnail and palette are redrawn unless a custom thumbnail is set), Custom Thumbnail from Clipboard (a copied jpg, png, gif, bmp or webp file as it is, else copied image data as a PNG), Thumbnail Background ▸ (None, Black, Gray, White, Checkerboard; drawn behind the image in the grid and the viewer with Eagle’s colours (#000, #888 at 50%, #fff, and a white checkerboard of #e0e0e0 5 px squares), the viewer’s in place of the transparency checkerboard, and stored in the item’s background key as Eagle stores it: dark, gray, light, grid, removed for None), Set as Folder Cover (inside a folder), Set Custom Thumbnail… and Reset Thumbnail, Refresh Thumbnail, Refresh Palette, Rotate Left and Right, Flip Horizontal and Vertical, and Crop… (opens the viewer with the crop tool) |
| Folder view | Cards for the folder’s subfolders above its items, each with its cover, name and counts, under a heading with the Show Subfolder Content switch. Click the heading to fold the cards away (remembered for every folder, as Eagle’s isHideSubFolder). Click one to open it; double-click its name to rename the folder (Return or a click elsewhere saves, Esc cancels). Items dragged from the grid can be dropped on a card (it lights up like a sidebar row), with the same effect as dropping on that folder in the sidebar. Right-click a card for Rename, Auto-Tags…, Password, Reset Cover, Export and Delete Folder, the same entries as the sidebar’s folder menu (Rename, Auto-Tags…, Reset Cover, Export and Delete are off while the folder is locked). Reset Cover is a falcon addition (Eagle has no such command): it shows in the sidebar’s single-folder menu and on the cards only while the folder has a coverId, removes that key from metadata.json (the other keys keep their order) so the default cover shows again, names the folder in a note, and is one ⌘Z step like Set as Folder Cover. On a folder with no custom cover it does nothing and writes nothing |
| All Tags | A tag manager with its own sidebar (All, Uncategorized, Starred, then the tag groups; see All Tags page), a filter, starred tags, several tags picked at once (click, ⌘, ⇧), rename, merge, star, delete, add to or move between tag groups, sort by name or count, grouped A-Z or by tag group, and Clear Tag History (see All Tags page) |
| Duplicates | Finds identical files or similar pictures in all items, the current list or the selection, picks the best copy, and merges each group into it (see below) |
| Import | Drag and drop, ⌘V (files or images), files passed to the app, and the collect window (see below). Where a drop lands matters, as in Eagle: on the grid, files and the contents of folders go flat into the open folder; on a sidebar folder, files go into that folder; on the rest of the sidebar, each folder is imported with its subfolder tree recreated as Eagle folders at the top level, and the new folder opens. A dropped .eaglepack goes to the package import wherever it lands (into the folder it is dropped on, or the open folder when dropped on the rest of the sidebar) (see Importing folders). The library menu also has Import Files… and Import Folders… (several folders, each with its structure kept), and the empty All and folder pages have an Import Folders… button. A folder with 2,000 files or more asks first, and one 15 levels deep or with 100,000 files or more is refused, as in Eagle. Pixave libraries are skipped. Files that fail to import are listed with a reason in a panel opened from the notice under the toolbar, with Retry All, Copy to Clipboard and Clear All (see Failed imports). As in Eagle, any file with an extension imports (as an icon when nothing can draw it); files with no extension and unfinished browser downloads are refused into that list. Imports run in the background with a progress bar. PDF, video, PSD, AI, HEIC and RAW go through QuickLook. A dropped or pasted *.library folder is opened as a library (as from the switcher), never imported; if several are dropped at once, only the first opens and a message says how many were left closed. As in Eagle, this applies to the dropped paths themselves, not to .library folders found inside a dropped folder |
| Smart folders | Create and edit them with every condition (each matched by any or all of its rules, and inverted or not) and every rule property, inside another smart folder, with an icon and colour. Rules falcon can’t edit are kept. Refresh in a smart folder’s menu (not a group’s) is a falcon addition; Eagle has no such entry. It writes anything pending, reads the library again from disk, so changes made by another program show up, and says “{name} now has {n} items”. The view and selection stay as they are |
| Trash | Restore, Delete Permanently and Empty Trash, all with confirmation. The Trash entry’s menu also has Restore All Items, which asks first, as Eagle does; unlike Eagle, ⌘Z undoes it. Empty Trash runs behind a progress modal (title, bar, “3 of 10” and the item being deleted) with a Cancel button; items already deleted stay deleted and the rest stay in the Trash, and the undo history is cleared as for any permanent delete. While a progress modal is open the Dock icon shows the same progress as a bar along its bottom edge. Moving items to the Trash inside a folder (Delete, the menus, the inspector) when some of them are in other folders too asks, as Eagle does, whether to only take them out of this folder (items in no other folder still go to the Trash) or to trash them all; the choice is remembered until falcon quits. Dragging onto the Trash doesn’t ask |
| HTTP API | Runs while the app is open, from port 41596 up |
The Rename dialog for several items is Eagle’s: a name template (default Untitled - %N, then the last one used) with counters %N…%NNNN, dates %D, %M (file modified) and %B (created) in five forms, %T tags and %F folder names (each sorted and joined with -) and * the original name, counting from Start at; or a find and replace (plain text or /pattern/flags). A case change applies to either, a preview lists every new name, and ten or more items ask first. As in Eagle, templated names lose /, % and emoji, and an emptied name keeps the original; the library then makes the name safe for a file and renames the original and its thumbnail.
Empty views
An empty grid says why, with an icon, a title and a line of text (crates/falcon-app/src/ui/grid/empty.rs, where the wording and rules are tested). While a search or any filter is active it reads “No Matches”. Otherwise All, Recently Used and Random say “Drop Files Here” with an Import Folders… button, and so does a folder with no subfolders (a folder with subfolders shows only its subfolder cards, and a locked one its lock screen). A smart folder with no matches says so. Uncategorized and Untagged explain what gathers there, or say “All Sorted” and “All Tagged” when the library has items and none are left. The Trash says it is empty. The All Tags page says “No Tags Yet” when the library has no tags, and “No Matches” when its filter finds none.
Date, duration, BPM, camera and font filters
Five chips follow the existing ones in the filter bar. They set the imported, modified, created, duration, bpm, cameras and font rules of the library’s Filter (see Search and filters), so the grid applies them like any other rule. The code is in crates/falcon-app/src/ui/filter_bar/media.rs, with the parsing and labels in logic/ (BPM’s are in ui/filter_bar/media.rs).
What Eagle does, from js/directives/filter-item-import.html, filter-item-mtime.html, filter-item-duration.html, filter-item-bpm.html and .js, filter-item-camera.html and filter-item-fonts.html:
| Question | Eagle | Seen or inferred | falcon |
|---|---|---|---|
| Which chips show | Every filter item is hidden unless it is active or pinned (hide-filter when !isEnabled && !pinned[type]); pinning is a user choice | Seen | Date always shows. Duration, BPM, Camera and Font show only when the library has an item they can match (a duration, a non-zero bpm, a camera, a font) or they are active. There is no pinning |
| Date | Two separate items, Date Imported and Date Modified. Each menu has the six presets (Today, Yesterday, Last 7, 30, 90 and 365 days) with counts, then every month that has items (YYYY-MM, newest first, with counts), then a Range checkbox that opens a calendar picker | Seen | One Date chip with a tab for Imported, Modified and Created (Created is falcon’s own; Eagle only has it in smart folders). Each tab has the presets with counts, a range typed as two days (2026-01-01 – 2026-12-31, swapped if reversed, both needed), and the month list with counts. There is no calendar widget |
| Duration | Min and max text fields and a unit (seconds, minutes, hours) | Seen | Same, and a field also accepts a time such as 1:30. The placeholders show the library’s shortest and longest duration. The chip reads 0:30 – 2:00, ≥ 0:30 or ≤ 2:00 |
| BPM | A 160 px menu with Min and Max text fields and no unit. The chip reads 90≤BPM≤128, BPM≥90 or BPM≤128 from the typed text, and only bounds above 0 count for the label. The search reads each bound with parseInt. The find.filter.bpm shortcut has no default | Seen | Same two fields, read the way parseInt reads them (120.5 and 120 bpm give 120). The placeholders show the library’s lowest and highest tempo. The chip reads BPM: 90 – 128, BPM: ≥ 90 or BPM: ≤ 128, like Duration. Applying a saved filter fills the fields from its bounds. No shortcut |
| Camera | Every distinct rawMetas.camera value with its count; several can be ticked; “no results” when there are none | Seen | Same, counting items not in the Trash |
| Font | Two exclusive options, Activated and Deactivated, each with its count and shown only when its count is above 0 | Seen | Both options always listed with counts. “Activated” means the font’s <postScriptName>_.<ext> key matches a file in ~/Library/Fonts/EagleApp/ (Eagle’s folder) or in ~/Library/Fonts/Falcon/ (falcon’s own), read once per session (search::installed_font_keys_in over search::font_dirs). Eagle looks only at EagleApp/, so a font activated by falcon counts as not activated in Eagle. On a Mac where neither folder exists or holds fonts, every font counts as deactivated. The font viewer’s Activate button writes Falcon/, and the counts are read again after it |
| Clearing | A clear button on each item, and right-click | Seen | A Clear button inside each menu, and the bar’s Clear for everything |
Viewer layout
Every viewer (image, GIF, RAW, SVG, video, audio, font, text, PDF, the cannot-preview page and the iCloud wait screen) opens in the middle of the window, in place of the toolbar, filter bar and grid, with the sidebar and the inspector still beside it, as Eagle’s detail mode does. The inspector shows the viewed item: the selection follows it as you step through, so closing the viewer leaves the grid on that item. The viewer draws only inside its own rect, and its scroll, pinch, clicks and menus act only there. The span is logic::viewer_span; the bar is media_view::bar_shell, shared by every viewer.
What Eagle does, from the detail mode in index.html and app.bundle.js:
| Part | Eagle | Status | Falcon |
|---|---|---|---|
| Placement | content-panel detail-mode between the sidebar and inspector widths; the main toolbar and grid are hidden while it shows | Seen | Same |
| Panels button | toggle-all-btn at the left of both the main toolbar and the detail toolbar. toggleAll shows both panels when the sidebar is hidden and hides both otherwise; it doesn’t remember each panel’s state. Right-click lists both panels (Tab), the sidebar and the inspector | Seen | Same button in both bars, same rule (logic::toggle_all) and the same three-entry menu |
| Panel keys | View menu: ⌘⌥1 sidebar, ⌘⌥2 inspector, ⌘⌥3 both (view.toggle.all); Tab is a fixed key for both | Seen | Same keys, in the grid and in the viewer. ⌘⌥3 can be rebound; Tab can’t and is ignored while a text field has focus |
| Counter | “n / total” after the exit button | Seen | Same, counted in the grid’s order (logic::viewer_counter) |
| Previous and next | Buttons at the right end of the detail toolbar, disabled on the first and last item, and the arrow keys | Seen | Same |
| At the ends | A short “already the first / last item” tip in the middle of the viewer | Seen | A status note in the middle of the viewer for about a second (logic::viewer_step) |
| Double-click | Leaves the viewer anywhere in the image viewer, the picture included, except while cropping. Video and audio stop the double-click; PDF, font and text viewers are iframes that keep it | Seen | Same for the image viewer (also not in comment mode). Before, falcon reset the zoom on double-click; Fit and 0 still do |
| Right-click | Opens the item’s context menu (mouse-gesture.js) | Seen | The grid card’s menu, without Open, in the image viewer and over the audio and font viewers’ background. Trashing or restoring the viewed item moves the viewer to the next item |
| Zoom keys | view.zoom.in ⌘=, view.zoom.out ⌘−, view.zoom.actual ⌘0, view.zoom.fit ⌘9 | Seen | Same chords and ids, rebindable; falcon also keeps plain + and − (without ⌘, ⌃ or ⌥). Plain 0 and 1 rate the item, as in Eagle |
| Cannot preview | An illustration, the file name, a line saying the format can’t be previewed and an “Open With Other Application…” button that opens the item menu | Seen | The file icon with its extension, the name, a line and an “Open With Another App…” button whose menu has Open in Default App, Open With and Show in Finder. Shown only when neither a full render nor the thumbnail can be shown; a failed full render falls back to the thumbnail |
| Presentation mode | toggleSlideshow (F5, view.toggle.slideshow; Enter / Leave Presentation Mode in the item menu and the View menu; a middle click in the viewer): opens the first selected item in the viewer, makes the window full screen (setFullScreen) and hides the panels and the viewer toolbar (body.slideshow). There is no timer, no autoplay and no shuffle: the arrows step through items as in the viewer. Esc leaves presentation mode but keeps the viewer; leaving full screen any other way ends it too | Seen | Same (ui/viewer/modes.rs): F5 (rebindable), Presentation Mode in the View menu with a checkmark, Enter / Leave Presentation Mode in the item menu, a middle click anywhere in any viewer (image, GIF, video, audio, font, text, PDF, 3D and the other pages; a middle drag in the 3D viewer still pans, since only a click without movement counts; over a live web page the native WKWebView takes the mouse, so there the click works on the viewer bar only), and the video player’s full-screen button. The window goes full screen through eframe’s ViewportCommand::Fullscreen, the sidebar and inspector hide without forgetting their own state, and the viewer’s bar is placed above the window so the picture has the whole screen. Every viewer key keeps working. Esc, F5 or the menu leave it and put the window back as it was (full screen or not); closing the viewer or leaving full screen with the green button or ⌃⌘F ends it too |
| Always on top | View ▸ Always on Top, a checkbox, ⇧T (view.alwaysOnTop): setAlwaysOnTop(true, "pop-up-menu"); off at every launch | Seen | Same (App::toggle_on_top): ⇧T (rebindable) or View ▸ Always on Top, first in the menu as in Eagle, with a checkmark. eframe’s ViewportCommand::WindowLevel sets the main window’s level; not remembered between launches |
| Grayscale | View ▸ Grayscale and the item menu’s Grayscale (⌥⌘G, view.grayscale) toggle isGrayscaleMode, which puts CSS filter: grayscale(1) on the grid’s thumbnails (not custom ones), the viewer’s image, video and iframes (PDF, fonts, web) and the full-resolution canvas. Off at every launch. It is one flag of the main window: it grays that window’s grid and viewer and has no effect on other windows, and it is not saved | Seen | ⌥⌘G (rebindable), View ▸ Grayscale with a checkmark, and Grayscale in the item menu. egui has no shaders, so the image viewer’s pictures are decoded again desaturated with CSS’s luminance weights (logic::grayscale_rgba): the preview, the 3072 px render, the full-resolution tiles and an SVG’s sharp patch, on the loader’s workers. The colour pictures stay on screen until the gray ones arrive, so switching doesn’t blank the view. GIFs and other animations restart in grayscale. The grid’s cards follow it too (every loader tier, and the cards’ GIF players restart gray), and so does everything else the viewers draw: video frames are desaturated before upload (a paused frame is decoded again on a switch), the PDF reader’s pages and thumbnails are rendered again gray on its worker, and the font specimen’s theme colours, swatches and accent are grayed (logic::gray_color). A web page gets filter: grayscale(1) on its root element, which covers its frames, set by a user script on each new document and on the one shown, and removed again on the way back; nothing is loaded for it. The 3D pane is desaturated when its picture is copied to the window, background, edges, grid and axes included, while Create Snapshot and thumbnails stay in colour; the bar, the panels and the measure labels over it are egui’s and stay in colour. Unlike Eagle, custom thumbnails gray as well, and so do the inspector’s and covers’ copies of a card’s picture, which share its texture |
| Navigator | .smooth_zoom_navigator: a 120 px box 16 px in from the bottom-right corner (black at 40%, a faint border, 6 px corners) with the picture fitted inside at 90% opacity, the area outside the visible part darkened and the visible part framed in white (at least 8 px). It shows while the zoomed picture overflows the view (97% of its width wider than the view, or taller). A click centres the view on that point; dragging the frame pans. Navigator in the item menu (viewer only) toggles it, kept in local storage (isHideNavigator); ⌥⌘8 (view.toggle.navigator) is listed in the shortcut table, but nothing in 4.0.0’s main window answers it | Seen | Same box, colours, rule and behaviour (logic::navigator_shown, navigator_picture, visible_part, pan_to_show): a click centres the view on the point, dragging the frame moves it from where it was taken. Navigator in the item menu (in the viewer) and View ▸ Navigator toggle it, and ⌥⌘8 (rebindable) does too; hidden or not is kept with the app state |
| Separate window | Open in New Window (⌘O, view.file.opennewwindow, the item menu, middle click by preference): preview-window.html, one window per item with every viewer, a title bar with Always on Top, opacity 10–100%, and a menu with the navigator, grayscale, rotate, flip, zoom presets, copy, open, show in Finder and Reveal in Eagle | Seen | Open in New Window (⌘O, rebindable), in the View menu and the item menu, shows every acted-on item except audio (as Eagle’s openInPreviewWindow; a middle click opens the one card) in a new eframe viewport (ui/viewer/preview_window/), as the collect window does: each open makes another window, and ← and → step through its items, stopping at either end, with the position in the title (not in the text and PDF viewers, which keep the arrows; Always on Top and the opacity stay, the view, rotation and players start over). It is sized to the picture (at most 1000 × 760, preview_window::window_size). Pictures: the full render (else the thumbnail) fitted without enlarging, sharpened on zoom by the main viewer’s sharp renders (ask_sharp: tiled originals past the 3072 px tier, PSD merged image, PDFKit for PDF-compatible AI, QuickLook for PostScript-only AI and Office slides, SVG redrawn) on the window’s own loader, not while the window’s view is turned and not for animations; not yet seen on screen; the wheel or a pinch zooms around the pointer, dragging pans, a double-click fits; while the picture overflows the window the navigator shows at the bottom right as in the image viewer (click centres, drag pans). Video has a player of its own (falcon_video::Video, autoplay by preference, click or Space plays and pauses, a thin seek bar with the time along the bottom, fitted without enlarging), audio a second Audio with the waveform as seek bar, a play button and the time, text files (.txt, the first 2 MB, lossy UTF-8) a read-only wrapped monospace page, and PDFs a single-page reader (a worker thread opens the document and renders the page at the window’s pixel size; ←/→ or PageUp/PageDown and the buttons step, n / m between them; a locked or unreadable document keeps the thumbnail). These players are the window’s own and stop when the item changes or the window closes; the main viewer’s keep playing. Fonts get the main viewer’s specimen page (article, waterfall and alphabet tabs, themes, zoom), drawn by the same code on a font state of the window’s own (preview_font), so the main viewer’s font page isn’t disturbed; a font egui can’t read gets the font viewer’s fallback (the QuickLook specimen and the Info tab). 3D models falcon reads get the main viewer’s 3D viewer (orbit, pan, zoom, the bar’s fit, edges, grid, axes, details, settings, snapshot and export) on a model and camera of the window’s own (preview_model); the scene goes through the same wgpu paint callback, which works in any window because every viewport shares egui’s one wgpu device and renderer, with GPU objects kept apart from the main viewer’s (SceneCallback::slot: 0 for the main viewer, 1 for the preview windows, which share theirs). Without wgpu, and for 3D formats falcon can’t read, the thumbnail shows. Bookmarks, HTML pages and MHTML archives (unpacked into a folder of the window’s own, removed when it closes or moves on) get the web viewer (preview_web): a WKWebView of the window’s own, put on the content view of the preview window’s NSWindow (found by its title, as egui gives an immediate viewport no window handle, the same way the opacity is set; WebView::sync_window), with back, forward, reload, Copy URL and Open in Browser in the bar, hidden while one of the window’s menus covers it. In both, the bar leaves out the main viewer’s back, counter and previous/next buttons, since the window steps through its own items. Over a model, the right-click opens the window’s menu rather than the item menu. Other types show their thumbnail. Right-click: Always on Top, Opacity (100% to 10%, the NSWindow’s alphaValue on macOS), Grayscale (the window’s own flag, off at start and kept while stepping items; every type: pictures through the window’s own loader, video frames and PDF pages desaturated before upload, audio and text drawn in gray, the web and 3D panes as in the main viewer; the key view.grayscale and View ▸ Grayscale in the menu bar act on the focused window, a preview window or the main one, and the menu’s checkmark shows that window’s flag), Actual Size, Zoom to Fit, Navigator, Rotate Left, Rotate Right, Flip Horizontal, Flip Vertical (in write mode, the default imageRotateMode, they rewrite the file through the main viewer’s rotate and flip for formats falcon can write, and in preview mode or for other formats only the window’s view turns; a video’s picture turns and mirrors in the view only; the keys edit.image.rotate and edit.image.flip are bound in the window; audio never opens in a preview window), Copy (the file), Copy File Path, a Copy submenu with Link (the local API link) and Eagle Link (eagle://item/<id>), as the item menu copies them, for video Copy Frame and Save Current Frame to Library (a JPEG item, as in the main viewer); the menu scrolls when the window is too short for it, Open in Default App, Show in Finder, Show in Falcon (selects the item in the main window and brings it to the front), Close Window; Esc or ⌘W closes it. A middle click on a card opens it here too (Preferences ▸ Controls, Prefs::middle_button; never audio, as in Eagle) |
| Mouse gestures | mouse-gesture.js on the image viewer, the too-large page and the preview window: with the right (or middle) button held, a mostly vertical drag zooms about the press point (×e per 100 px up below 100%, per 150 px from 100% on, held between 5% and 200%), and a sideways drag of over 20 px released within a second, not turned back by more than a third and without zooming, goes to the next item (rightwards) or the previous one (leftwards), with a fading blue trail; right-click without moving opens the item menu | Seen | Same rules (crate::gesture), driven from image::drive_gesture in the image viewer (pictures, GIFs, RAW and special formats) and on the too-large page; the trail is dots in the accent colour. The preview window has both: the zoom, and swipes stepping through the items it was opened with (stopping at either end, as ← and → do there). A left drag still pans; a middle click (no drag) keeps its presentation-mode role |
| Not built | Space’s inline quick preview | Seen | Not built |
Image viewer
Images, RAW photos, SVGs and the formats shown as a picture (PSD, Office files and the like) open in the image viewer, drawn in crates/falcon-app/src/ui/viewer/image.rs with its rules in logic/.
What Eagle does, from index.html, the BitmapViewer and zoomer in app.bundle.js and js/default-preferences.js:
| Part | Eagle | Status | Falcon |
|---|---|---|---|
| Resolution | The thumbnail first, then the original decoded in full on a worker and drawn in 500 px tiles, for jpg, png, webp, avif, jxl, bmp, svg, tiff and heic (BitmapViewer.isSupportFormat); other formats show the thumbnail | Seen | The preview, then a render of at most 3072 px (the full tier). Zoomed in so far that this render would be magnified, the original of a jpg, png, webp, bmp or tiff larger than 3072 px (and on macOS of an avif, heic, heif or jxl, through ImageIO, turned by its orientation; JPEG XL needs macOS 14, before which the QuickLook render stays) is decoded in full on a worker and cut into textures of at most 2048 px (never more than the GPU’s max_texture_side), with a pixel of overlap so the seams don’t show; the tiles are uploaded a few per frame and replace the render once all are up. Only the viewed item’s original is kept; it is dropped as soon as the viewer moves on or closes. The grid keeps its own 512 px tier. A 12000 × 8000 PNG decodes and tiles in about 0.2 s in a release build (loader::tests::large_original_decodes_off_the_ui_thread) and uploads over 8 frames |
| Special formats | PSD, PSB, AI, PPT, PPTX and POTX get Eagle’s native viewer (native-viewer/) over the thumbnail, which draws the file again at the size shown; an item with a custom thumbnail whose format has no viewer of its own shows the custom thumbnail (getViewerPluginExt returns custom when the format isn’t in SUPPORT_FORMATS) | Seen | Special formats show the 3072 px QuickLook render (macOS). For AI, PPT, PPTX and POTX, and for a PSD or PSB without a readable merged image (see the next row), drawn larger than that render, the page is rendered again at every zoom: once the view has held still for 0.4 s, QuickLook renders the whole page at the size shown (rounded up to a 512 px step, at most 8192² pixels and 16384 px on the long side; QuickLook hands PPT, PPTX and POTX back only as a bitmap or as HTML, so there is no PDF to draw by region past that, and for PSD and PSB never past the file’s own pixels), the part on screen is cut out and drawn over the render as an SVG’s sharp part is (logic::quicklook_region, loader::quicklook_region_job, Loader::want_region). The worker keeps that one page render (its memory is bounded by the pixel cap) so panning or zooming out cuts from it without running QuickLook again; it is dropped when the viewer leaves the item, and only the part on screen becomes a texture. Past the cap the last render is magnified. The separate preview window asks for the same renders on its own loader (not yet seen on screen). An AI or AIT file saved PDF-compatible (the worker checks for a %PDF header, since the extension says nothing about it) has no cap: after the same 0.4 s, PDFKit draws only the part on screen at the pixel size shown, from the first page (falcon_pdf::Document::render_part, loader::pdf_patch), so it is sharp at every zoom. QuickLook and PDFKit show the same picture: both draw the crop box (the media box without one), ignore the art and trim boxes, apply the page’s rotation and use an opaque white background, and pdf_compatible_ai_regions_line_up_with_quicklook checks this against qlmanage. The document is opened and closed for each render on one worker’s thread, so all that stays is the one patch, whose size follows the view and which is dropped on leaving the item. A PostScript-only AI file, or one PDFKit can’t open, keeps the QuickLook path and its cap. One region is drawn at a time for every format, and a newer view replaces one still waiting (loader::next_job). macOS only. A custom thumbnail is the viewer’s full picture for formats with no render of their own (logic::custom_thumbnail_is_picture: not an image falcon decodes, a RAW, TGA, HDR/EXR or a special format), so QuickLook isn’t tried first |
| PSD and PSB merged image | Eagle’s native viewer draws these files at the size shown | Seen | falcon_library::psd reads the merged image Photoshop stores in the file (PSD, PSDT and PSB; raw and RLE; bitmap, grayscale, indexed, RGB, CMYK, duotone and Lab, at the depths Photoshop allows). The full tier is scaled from it, so the render and the tiles match in colour. Zoomed in past 3072 px, it goes into the same tiles as a large JPEG up to 16384² pixels (logic::COMPOSITE_TILED_PIXELS), decoded row by row off the file: peak memory is the picture at 4 bytes a pixel, once while it is cut and once as textures, so 1 GiB at most each, plus at most 40 MB of compressed data and the row table. A larger picture stays out of tiles: once the view has held still for 0.4 s, only the rows on screen are decoded, scaled to the zoom shown (logic::composite_region, Loader::want_region), which costs what the screen shows however large the file. Reduced six times or more, rows are skipped so the whole picture stays quick. A 240-megapixel RLE file takes 0.7 s to tile at a 1.03 GB peak; a 1200-megapixel one gives its full tier in 1.1 s and a screenful in 50 ms at a 117 MB peak (loader::tests::large_psd_decodes_within_its_memory_bound). Colour: an embedded ICC profile is applied for RGB, grayscale, CMYK and indexed (moxcms, matching ColorSync to within 2 levels on Generic CMYK, Adobe RGB, Display P3 and Generic Gray); without one, RGB and gray count as sRGB, 32-bit samples as linear sRGB, and CMYK uses the naive R = (1 - C)(1 - K). Lab converts from its D50 definition, and duotone shows the gray channel the file stores because its ink curves are undocumented. The EXIF orientation in the image resources turns the picture (Apple’s decoders ignore it, so such a file’s QuickLook thumbnail can differ). Transparency comes from the first extra channel only when the layer section says the merged image has it (a negative layer count, or an Mtrn, Mt16 or Mt32 block), and the white Photoshop blends such an image over is taken back out; other extra channels are selections or spot colours. A file saved without a merged image (Maximize Compatibility off, recorded in the version info resource), a multichannel or Zip-compressed one, or a damaged one falls back to QuickLook as in the row above, and while it is in the viewer the inspector has a Render row naming the reason. A header’s size is checked against the file’s length before any pixel memory is taken, so a 30000 × 30000 header on a small file is refused at once. With sharp pixels on, the part-on-screen path past the tile budget stays smooth. The separate preview window uses the same renders (not yet seen on screen) |
| TIFF pages | The viewer decodes only the first page of a TIFF (its worker reads the first IFD) | Beyond Eagle | A TIFF with more than one page (counted on a background thread when the viewer opens it) shows a page control in the image viewer’s bar, left of the edit tools: a previous button, the page as 2 / 5 in the mono font, and a next button. PageUp and PageDown step the pages too (they don’t scroll the grid behind). Page 1 is drawn as any TIFF. A later page is decoded on a worker (8- and 16-bit gray, gray with alpha, RGB, RGBA and 8-bit CMYK) and drawn at up to 3072 px, fitted by its own size, since pages can differ in size; the previous picture stays up while it loads. Zoom and pan reset to fit on each page change, and the page returns to 1 when the viewer moves to another item. A later page has no full-resolution tiles and no too-large check; crop, comments and edits act on the file as before. The grayscale preview applies to every page |
| TGA, HDR and EXR | Radiance HDR and OpenEXR are tone mapped with Reinhard at exposure 1.0 and sRGB output (texture-viewer/); the exposure is fixed, with no slider in the UI | Seen | TGA is decoded by falcon itself, at full resolution (the full tier and, zoomed in, the tiles), instead of a QuickLook still; the too-large rule above still applies. HDR and EXR are decoded to linear floats (falcon_library::hdr) and tone mapped per channel as c × exposure, c / (1 + c), then the sRGB encode, with the file’s alpha for EXR. An exposure slider, an extra over Eagle (−5 to +5 stops in 0.1 steps, shown as +1.0 EV; double-click resets) sits in the bar for these items. The loader keeps the last linear image, so moving the slider tone maps again without reading the file, and the previous picture stays up until the new one arrives. The linear copy is reduced to the 3072 px full tier before tone mapping unless the full-resolution original was asked for. The exposure is for the session and returns to 0 on another item |
| RAW | raw-viewer/raw-player.js shows the thumbnail, then the JPEG preview dcraw extracts from the file (extractThumbnail), turned by the item’s orientation | Seen | The thumbnail, then the largest baseline or progressive 8-bit JPEG embedded in the file (thumbnail::raw_preview; lossless raw data is skipped), turned by the file’s EXIF orientation: from the TIFF IFDs of TIFF-based files, the EXIF of the JPEG preview inside RAF, the CMT1 block of CR3, the \0TTW TIFF of MRW, the JPEG preview of X3F and the ImageInfo rotation record of CRW (exif::orientation). It feeds the 3072 px render and, zoomed in, the full-resolution tiles. Files with no embedded JPEG fall back to a QuickLook render on macOS. This works off macOS too |
| SVG | Drawn by Chromium as an <img>, so it stays sharp at every zoom | Seen | Rendered by resvg: first at up to 3072 px, then, once zoomed in past that and the view has held still for 0.15 s, the part on screen is rendered again at the screen’s pixel size (at most 8192 px, and never over the GPU’s texture limit) on a worker (thumbnail::render_svg_region, logic::svg_region) and drawn over it. The coarse render shows around it while panning until the next sharp render arrives |
| Opening size | “Smart Zoom” (smartZoom): fit inside the view, never enlarged. A long image (at least 960 px wide and over 1.8 times as tall, or at least 320 px wide and over 2.7 times as tall) fills the width, at most 100%, and opens at its top. Names ending in @2x, @3x, @1.5x or @0.5x open at 50%, 33%, 67% or 200% when that fits. Phone-screen sizes get their own scale. There is no rule for wide strips | Seen | Same rules (logic::smart_fit, logic::is_long_image, logic::density_scale), fitting 40 px inside the view. Phone-screen sizes aren’t special-cased |
| Zoom controls | The detail toolbar has a slider whose positions run 5 to 200, each step above 100 worth 7%, so it spans 5% to 800%; it snaps to 25, 50, 100, 125, 150, 175 and 200% within 3%. The % label opens a menu of 5, 10, 25, 50, 100, 125, 150, 200, 300, 400 and 800%, Actual Size and Zoom to Fit; right-clicking it toggles actual size and fit. Then an Actual Size button and the Fit to Window toggle (backtick), which makes every image fill the view, enlarged if small, until switched off or Zoom to Fit is chosen; the mode is kept in localStorage (eagle.viewer.lastZoomMode). Zoom stays between 5% and 800% | Seen | Same slider, snapping, menu, right-click and limits (logic::slider_zoom, logic::ZOOM_PRESETS, logic::edge_fit). The bar has the slider, the % label, a 1:1 button and a Fit button that is the backtick toggle and shows when it is on. The mode is saved with the app state. Sizes from the slider and menu zoom about the centre of the view |
| Too large | TIFF, HEIC/HEIF and TGA items over 120,000,000 pixels (MAX_DIMENSION) without a custom thumbnail show a page with the thumbnail, a title, a line giving the limit and an Open With button that opens the item menu (pages.tooBigPreview) | Seen | The same rule (logic::too_large_to_preview): a page with the thumbnail, “Too large to preview”, a line giving the limit and the image’s megapixels, and an Open With Another App… button that opens the item menu, as Eagle’s does. Right-click opens the item menu too and double-click leaves. No full render is made for such items |
Audio and font viewer
Opening an audio item (Eagle’s mp3, wav, flac, ogg, aac and m4a; see Audio formats) shows its waveform with playback controls instead of an image; opening a font shows a specimen drawn with the font itself. The code is in crates/falcon-app/src/audio.rs (playback and the waveform cache), crates/falcon-app/src/ui/viewer/audio.rs (the audio view), crates/falcon-app/src/ui/viewer/font.rs (the font viewer) and crates/falcon-app/src/font_install.rs (activation), with the seek and time arithmetic in logic/, the font information in fontmeta::FontInfo and the language change in Library::set_font_language.
What Eagle does, from js/directives/audio-media-element.js, its video.js setup in app.bundle.js, js/default-preferences.js and font-viewer/:
| Question | Eagle | Seen or inferred | falcon |
|---|---|---|---|
| Player | video.js controls (play, volume, time, progress, loop, playback rate) over an HTML media element; wavesurfer draws the waveform inside the progress bar, 40 px high, unplayed #7C7C7C, played and cursor #0072EF, normalised | Seen | A waveform 160 pt high in the same two colours, normalised, with a playhead; under it a play/pause button, elapsed / total time, speed, loop and volume |
| Start | Plays as soon as the item opens; loop is on | Seen | Same |
| Seeking | Click on the progress bar; ⌘← and ⌘→ step by 2% of the length, at most 10 s; ⌘↑ and ⌘↓ change the volume by 5%; [ ] (⇧ for ten) move by a frame time, 1/24 s | Seen | Click or drag on the waveform, and the same keys from the Player group of the shortcut table (rebinding them there changes them for video and audio alike). Plain ← and → still move to the previous and next item |
| Space | Bound to player.playAndPause | Seen | Plays and pauses while an audio item is in the viewer (the Player group’s key); elsewhere it still opens and closes the viewer |
| Speed | A rate menu with 0.5–2×; ⇧. and ⇧, step through 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 3, 4 and 8×. The browser keeps the pitch | Seen | Same menu and steps, 1× for each new track. rodio resamples, so the pitch moves with the speed |
| Volume tip | The same tip as the video player on every volume change | Seen | Same |
| Volume | Remembered in local storage, 100% by default | Seen | 100% at launch, kept until quit |
| Waveform data | Decoded in the page each time, nothing cached on disk | Seen | Peaks computed once per item on a background thread with waveform::peaks and kept in memory for the session. “Reading waveform…” shows meanwhile |
| Audio output | Chromium | Seen | rodio on the default output device, opened on first play and kept open. Only an output stream is opened, which macOS allows without a permission prompt; no input device is ever touched |
| Decoding | Chromium’s media stack, whatever it plays | Seen | falcon_library::audio (see Audio formats), the one decoder behind playback, seeking, the waveform, the stored duration and the tempo. A file it can’t decode shows “Can’t play this file:” with the reason (for example “unsupported audio format”) and a flat waveform. An item whose record has no duration takes the decoded length for the seek bar and the time |
| Font viewer | A separate page: a header with the file name (editable) and an Activate button, the PostScript name under it; four tabs (article, waterfall, glyphs, information) whose choice is remembered; five colour themes; a size slider | Seen | The same layout drawn by falcon: header with the editable name, an Activate / Activated button and the PostScript name; tabs Article, Waterfall, Alphabet and Info; theme swatches and a sample-language menu at the right. The tab and theme are saved with the app’s state |
| Size | The slider sets the page’s root size from 31.25% to 625% (62.5% by default); zoom buttons step 31.25; ⌘= ⌘+ and ⌘− step, ⌘0 and ⌘9 reset; ⌘, ⌃ or ⌥ with the wheel steps (at most one step per 200 ms). The slider is hidden on the information tab | Seen | The same range as a factor from 50% to 1000%, with − and + buttons stepping 50%, the zoom chords from the shortcut table (and plain + and −), ⌘-scroll, pinch or ⌥-scroll stepping at most every 0.2 s, and the percentage, which resets to 100% when clicked. Hidden on Info |
| Article | Translated sample paragraphs per language with a heading, editable (rich text) | Seen | falcon’s own heading (38 pt at 100%) and paragraphs (18 pt), in the sample language; the paragraphs are editable plain text. Typed text stays as you move between fonts until Reset Text |
| Waterfall | One line at 72, 64, 56, 48, 38, 30, 24, 20, 16, 14 and 12 px (times the zoom), each editable, an edit copied to every line | Seen | Same sizes, each line editable, all sharing one text |
| Glyphs | Groups per language (Latin letters, numbers, symbols, Cyrillic, kana, kanji, hangul…) as cells; hovering a cell enlarges the character | Seen | falcon’s own groups (letters, numbers, punctuation, accented Latin, or kana, kanji, hangul or hanzi for those languages) as 25 pt cells, keeping only characters the font has; hovering shows the character large with its code point |
| Themes | Auto (the app’s theme), light (white), dark (#18191C), purple (#3D4599) and yellow (#FAE13C), each with matching text | Seen | Same five, Auto following falcon’s light or dark appearance |
| Information | PostScript name, full name, family (each with a “set as file name” button), style, version, designer, manufacturer, description, copyright (or licence), trademark and glyph count, in English names or the first language | Seen | Same rows from fontMetas, empty ones left out; “Use as File Name” renames the item (and its files) through the library, undoable with ⌘Z. Editing the name in the header renames too |
| Sample language | More ▸ change language: English, Japanese, Korean, Simplified or Traditional Chinese sets fontMetas.preferLng and regenerates the thumbnail; the viewer follows it | Seen | The language menu in the toolbar does the same (Library::set_font_language: preferLng set, specimen thumbnail and palette drawn again) and the viewer’s texts follow |
| Activation | Activate copies the original to ~/Library/Fonts/EagleApp/<postScriptName>.<ext> (PostScript name from the first language), Deactivate deletes that copy; no confirmation. A font counts as active when that folder holds a file whose <base>_<.ext> matches <postScriptName>_.<ext> | Seen | Same file naming and key test (font_install.rs), but in falcon’s own folder: Activate copies the font to ~/Library/Fonts/Falcon/<postScriptName>.<ext> (search::falcon_font_dir) and Deactivate removes only from Falcon/. falcon never adds to or removes from ~/Library/Fonts/EagleApp/, which a real Eagle manages. A font counts as activated when its key matches a file in either folder. Deactivate is offered only for fonts in Falcon/; a font only in EagleApp/ shows “Activated by Eagle” and cannot be deactivated from falcon. Activation happens at once, without a confirmation, as in Eagle; an existing copy is kept, the copy is written beside the target and moved in, and names that could leave the folder are refused. The Font filter’s counts are read again afterwards. macOS only. Tests use temp folders |
| Rating keys | 0 removes the rating and 1–5 set it from inside the viewer | Seen | Same: in every viewer (image, GIF, RAW, video, audio, font, text, PDF, 3D, web) and the grid the bare digits rate; zoom to fit and actual size are ⌘9 and ⌘0 |
| Rendering | Chromium with the font loaded as a web font | Seen | The font file (WOFF unpacked to sfnt; the first face of a collection) is added to egui’s fonts under its own family the first time it is viewed, and stays for the session. A font egui’s parser rejects (some WOFF2 and CFF files) keeps the font viewer’s header (editable name, Activate button, PostScript name) and gets two tabs: Specimen, QuickLook’s rendering of the file (else the specimen thumbnail), and Info, the same rows with Use as File Name. Themes, the size slider, the language menu and the drawn tabs need egui to read the font, so they are left out; the digits rate as in the other font views |
| Sample text | Chosen from the font’s script support, or fontMetas.preferLng | Seen | Same choice as the specimen thumbnail (Latin, Chinese, Japanese or Korean) |
Audio formats
Eagle 4.0.0 treats six extensions as audio (mp3, wav, ogg, flac, aac, m4a: the audio viewer’s ng-switch-when in index.html, the hover selectors in js/hover-preview/audio-hover-preview.js and the thumbnail list in app.bundle.js) and plays them with Chromium. Every other sound file (opus, aiff, wma, ape, amr, caf…) is a plain file there, with no player, waveform or duration. Falcon does the same: only those six are audio items (media::AUDIO_EXTENSIONS), and other sound files import as plain items with a QuickLook thumbnail. Opus is decoded inside .ogg and .m4a files, which Chromium plays too.
Decoding is falcon_library::audio. audio::open returns an AudioSource: interleaved f32 blocks, the rate and channels, the length and an accurate seek. The first backend is symphonia 0.5.5 (pure Rust, MPL-2.0) with the pure-Rust opus-decoder crate for Opus; when it can’t read a file and an LGPL FFmpeg is installed, audio::open tries FFmpeg (audio/ffmpeg.rs, see Video playback), which also reads the soundtracks of video files. The app wraps the source as a rodio source; waveform::peaks, media::audio_duration and tempo::bpm read the same source.
| Extension | Codecs that decode |
|---|---|
| mp3 | MPEG-1, 2 and 2.5 Layer III, with LAME/Xing gapless trimming and ID3v2 tags skipped |
| wav | PCM (8 to 32 bit), IEEE float, A-law, µ-law, IMA and Microsoft ADPCM |
| flac | FLAC |
| ogg | Vorbis, Opus (mono, stereo and multichannel mapping family 1, with the pre-skip trimmed), FLAC |
| aac | AAC-LC in ADTS, MPEG-2 (FF F9, as Apple writes) and MPEG-4 (FF F1) headers, with or without CRC |
| m4a | AAC-LC, ALAC, FLAC and Opus in MP4, with the edit list or iTunes gapless tag (iTunSMPB) trimming the encoder priming |
Fixes falcon makes around symphonia 0.5.5, each in crates/falcon-library/src/audio/:
adts.rsreplaces symphonia’s ADTS reader, which only syncs onFF F1and guesses the length from the bitrate: it indexes every frame at open, so the length is exact and seeks land on the frame.edit.rsreads the MP4 edit list and theiTunSMPBtag, which symphonia parses but doesn’t apply, so an Apple AAC file is 1.000 s long rather than 1.152 s and its audio starts at 0.opus.rsadds the Opus decoder symphonia 0.5 lacks, and the source trims the Ogg pre-skip and decodes 80 ms before a seek’s time so the decoder settles.- An MP4 whose sample entry gives a different rate from the ALAC header plays at the codec’s rate.
- When a reader can’t seek from where it is, the source reopens the file and seeks again, and if a reader can’t seek at all it decodes from the top and drops up to the time.
Gaps:
- HE-AAC (SBR, PS) plays its AAC-LC core only, at the core’s bandwidth. AAC Main, LTP and more than two AAC channels don’t decode.
- Raw AAC carries no priming information, so an
.aacfile starts with the encoder’s silence (about 2112 frames) and reads that much longer than its source. - Speed changes move the pitch (rodio resamples), as described under Speed.
Text viewer
Opening a text item (.txt) shows its text on a page in the viewer, editable in place, instead of a QuickLook picture. The drawing is crates/falcon-app/src/ui/viewer/text.rs, the rules (when to save, how a file is read) are crates/falcon-app/src/textdoc.rs, and the save itself is Library::save_text in crates/falcon-library/src/text.rs.
What Eagle does, from text-editor/text-editor.js, text-editor.html and its stylesheet:
| Question | Eagle | Seen or inferred | falcon |
|---|---|---|---|
| Page | A page at most 920 px wide, centred, 30 px of padding; body text 14 px with 1.6 line spacing, wrapped, white on the dark themes and near-black on the light ones | Seen | The same page, drawn with egui in the theme’s text colour on the viewer’s background, with the item’s name above the text |
| Editing | The body is a plain-text editable area; Tab inserts a tab, Esc leaves the editor | Seen | An egui multi-line editor; Tab inserts a tab, Esc leaves the editor (a second Esc closes the viewer) |
| Undo | The browser’s own undo inside the editor | Seen | egui’s undo inside the editor (⌘Z and ⇧⌘Z while it has focus). Saves are not library undo steps |
| When it saves | 500 ms after the last key press, when the editor loses focus, on ⌘S, and before the viewer closes | Seen | The same, and also when the viewer moves to another item, the library closes or the app quits |
| How it saves | Writes the text to <name>.txt.<ms> beside the original, then moves it over the original | Seen | Same |
| Empty text | Never saved, so clearing a note leaves the file as it was | Seen | Same |
| Item metadata | Not touched: size and mtime keep their import values | Seen | size and mtime are refreshed from the new file and lastModified moves on, as with every item edit; btime and modificationTime stay. The record’s text (the card’s text) is replaced with the start of the new text |
| Card | No thumbnail: the grid card shows the item’s name as a heading, then the first 50 lines of the record’s text, each trimmed (body 14 px with 1.5 line spacing, the heading 16 px; 11–13 px and narrower margins on small and middle-sized boxes) | Seen | Same, drawn with egui and clipped to the card (ui/grid/text_card.rs, rules in text_cards.rs). An item whose record has no text (imported by an older falcon) has the start of its file read once per modification; Refresh Thumbnail writes the key. Cards under 60 pt, such as list rows, show the file-type placeholder |
| Encoding | Read and written as UTF-8 | Seen | UTF-8 (a byte order mark is kept). A file that isn’t UTF-8, or is larger than 2 MB, opens read-only with a note, so saving can’t change bytes falcon can’t show |
| Title | The page’s title element is commented out in Eagle 4.0.0’s text-editor.html; its rename code (changeName: renames on blur, an emptied title goes back to the name) is still there, and renaming happens in the inspector | Seen | The item’s name is an editable title at the top of the page, with that rule: it renames the item when it loses focus (↩ or Esc too), and an emptied title goes back to the name. A rename is one undo step |
| Text size | ⌘= / ⌘+ and ⌘− step the page scale by 31.25% between 62.5% and 187.5% (five sizes, body 14 to 42 px, the title with it); ⌘0 and ⌘9 go back to the smallest; the wheel with ⌘, ⌃ or ⌥ steps at most every 200 ms. The zoom toolbar is hidden for text items and the size resets for each item | Seen | The same five sizes, keys and wheel, plus a pinch, and − / percentage / + buttons in the viewer bar (the percentage goes back to the smallest). The size is kept across items and launches |
| Links | A click handler opens target="_blank" links in the browser, but the text is inserted as plain text, so no link is ever made and nothing opens | Seen | http://, https:// and www. addresses are underlined in the accent colour; ⌘-click opens one in the default browser (a plain click when the text is read-only). Only web addresses are ever opened (open_with::open_url) |
| Outside changes | Read again when the window regains focus and the file’s mtime differs (checkForUpdate), or with F5 or ⌘R; a file that became empty doesn’t replace text on screen | Seen | Checked once a second while the item is open (one stat), and read again when its mtime changed, unless typing is waiting to be saved (the save wins) or the file became empty while the page has text. F5 reads it again. ⌘R stays Rename |
| New text file | File ▸ New… lists blank templates (Sketch, Photoshop, Illustrator, XD, Text Document, Word, PowerPoint, Excel, Keynote, Pages, Numbers, MindNode, XMind); ⌥⇧N makes a text document. Each template is a blank file named Untitled.<ext>, imported like a dropped file into the open folder with the folder’s auto-tags; nothing opens it in the editor | Seen | Text Document in the New… menu, File ▸ New Text File and ⌥⇧N import an empty Untitled.txt into the open folder with its auto-tags, then open it in the text viewer with the cursor in the text. Sketch, Photoshop, Illustrator, Word, PowerPoint, Excel and XMind entries in the New… menu import a blank Untitled.sketch, .psd, .ai, .docx, .pptx, .xlsx or .xmind the same way and select it in the grid (no viewer opens). falcon writes these files itself (falcon_library::templates): minimal Office Open XML packages, an XMind zip with content.json, metadata.json and manifest.json in the current XMind format, a white 1920 × 1080 RGB 8-bit PSD (written from Adobe’s public file format specification, RLE compressed, no layers), a one-page 1366 × 768 pt PDF-compatible AI (a plain PDF; not opened in Photoshop or Illustrator) and a Sketch zip with one empty page and a white preview picture in document version 146 (written from Sketch’s public file format reference and JSON schemas; not opened in Sketch). XD, Keynote, Pages, Numbers and MindNode stay listed disabled: their formats are proprietary and falcon can’t write them correctly. Files are imported directly, so the duplicate check doesn’t ask about it. One undo step |
| Keys | 0 to 5 rate the item, ← and → step through items, only while the text doesn’t have focus (Mousetrap ignores keys typed into editable text) | Seen | Same: the digits, arrows, ⌫ and Space act only while neither the text nor the title has focus |
Video viewer
Opening a video shows it with a control bar instead of the image viewer. AVFoundation plays what macOS can (mp4, mov, m4v and the other QuickTime and MPEG-4 files with H.264, HEVC, ProRes and similar codecs). Files it refuses (Matroska, WebM, AVI, WMV, FLV, MPEG-TS and others) play through FFmpeg when it is installed, in the same viewer; see Video playback and the FFmpeg fallback. Both go through the falcon-video crate (crates/falcon-video), which keeps every Objective-C call in one module and hands the app RGBA frames to upload as a texture. FFmpeg is LGPL and loaded at run time, never linked, so falcon stays MIT/Apache-2.0. The drawing, keys and timed comments are in crates/falcon-app/src/ui/viewer/video.rs.
What Eagle does, from the mediaElement and mpvMediaElement directives and the comment dialogs in app.bundle.js, its stylesheets and js/default-preferences.js:
| Question | Eagle | Seen or inferred | falcon |
|---|---|---|---|
| Player | video.js over an HTML video element, falling back to a bundled mpv when the element can’t decode the file (no size or duration, or no frame within 1.5 s of playing) | Seen | AVFoundation’s AVPlayer, with frames copied out of an AVPlayerItemVideoOutput each time the window draws. A file AVFoundation refuses, or fails on while loading, plays through FFmpeg (falcon-ffmpeg) with the same controls, its sound through the app’s audio output. The 1.5 s no-frame check is not copied |
| Can’t play | When neither player can play the file, a page with an illustration, the file name, a line saying the video can’t be played and an “Open With Other Application” button | Seen | A page with the thumbnail (or the type’s placeholder), the file name, a line saying why and the Open With button; right-click gives the item menu. The line says neither macOS nor FFmpeg could play it, or, without FFmpeg, that macOS can’t play the format (naming it for mkv, webm, avi and the others FFmpeg would take) and the FFmpeg libraries weren’t found. Shown when Video::open refuses the file or both players fail while loading |
| Control bar | Play, back and forward, time, progress bar, volume, playback rate, loop, note (add comment) and full screen | Seen | Play, back, forward, previous and next frame, elapsed / total, add comment, speed, loop, mute, volume and full screen |
| Full screen | The button toggles Eagle’s presentation mode (full screen with the panels hidden, toggle-slideshow); in a preview window it makes that window full screen | Seen | Same: the button enters or leaves presentation mode (App::toggle_presentation), and shows Exit Full Screen while presenting |
| Flip and rotate | Flip and Rotate buttons in the viewer toolbar, also on ⇧F and ⇧R. Rotate turns the picture a quarter anticlockwise, or clockwise on a ⇧-click; flip mirrors it left to right. CSS transforms only; the file is untouched | Seen | Same buttons in the viewer bar and the same keys (edit.image.flip and edit.image.rotate, rebindable; the tooltips show the bound key), drawn by turning the picture’s texture coordinates. The view resets when another item opens (inferred: Eagle reuses its video element, so its turn may carry over) |
| Picture | Fitted to the window and never enlarged past the video’s own size (max-width/max-height set to the video size), unless the Zoom to Fill preference is on | Seen | Fitted and never enlarged, unless Preferences ▸ Preview stretches videos to fill the viewer. The size comes from the file, so records with width and height of 0 still show at the right shape. Rotated phone clips are shown upright |
| Start | Plays at once (video.autoPlay, on by default) and resumes where the item was left (video.rememberPosition, on), kept per item in local storage | Seen | Plays at once unless Preferences turns autoplay off, and resumes where the item was left unless that is off; positions are saved per item in the app state across launches (the latest 500). A clip left at its end starts from the top |
| Loop | Clips of 30 s or less always loop (video.loopShortVideo, on); longer ones follow the loop button, whose state is saved | Seen | Same, and short-clip looping can be turned off in Preferences; the loop setting is saved with the app’s state |
| Volume | Saved in local storage as a percentage, 100% by default; a muted player saves 0% | Seen | Saved with the app’s state, 100% by default. Mute is saved separately, so unmuting brings the old volume back |
| Speeds | 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 3, 4 and 8×, from a menu or ⇧. and ⇧, | Seen | Same list from the speed menu or the keys. Each video starts at 1× (inferred: the rate belongs to the video element) |
| Skipping | ⌘← and ⌘→ move 2% of the length, at most 10 s. The back button moves the same; the forward button 2% at most 20 s; both act on mouse-down and repeat every 100 ms while held | Seen | Same: the buttons act when pressed and every 0.1 s while held (Repeat) |
| Volume tip | Any volume change shows a small box at the top right of the player (12 px in) with a speaker icon for the level (off, under 30%, under 100%, full) and the percentage, hidden 1 s after the last change. There is no tip for seeking | Seen | Same box and levels for the keys, the wheel, the slider and mute |
| Keys | The player.* keybinds (play and pause, frames, volume, skip, speed) form their own Preferences group whose conflict scope is player | Seen | The same defaults in a Player group of Preferences → Shortcuts, N included (see Shortcut settings) |
| Frame steps | [ and ] one frame, ⇧[ and ⇧] ten frames. The HTML player moves by a measured frame time (1/24 s until measured); mpv steps real frames | Seen | Real frames, through AVPlayerItem’s frame stepping. Stepping pauses |
| Seek bar | Click or drag; playing pauses while dragging and resumes after. Hovering shows a thumbnail preview of that moment (a second, muted video element seeked to the pointer’s time). A resizable range on the bar limits playback to a section | Seen | Click or drag, pausing while dragging. Hovering or dragging shows the picture at the pointer’s time in a 160 pt box above the bar with the time under it. A worker thread keeps one falcon_video::Grabber (an image generator on the file, or an FFmpeg decoder for formats macOS can’t play, pictures at most 320 px) and always decodes the newest time asked for, skipping requests that piled up; a new request is only sent once the pointer has moved by 1/400 of the clip or a frame. No range |
| Scrolling | Scrolling over the video seeks (the habits.videoScrollBehavior default, “progress”): 2% of the length at most 10 s per wheel step, less for small trackpad moves; scrolling up or right goes back | Seen | Same direction and steps; Preferences can make the wheel set the volume instead (5% a notch, up is louder) |
| Clicking | Click plays or pauses (video.js); double-click leaves the viewer | Seen (double-click); inferred (click) | Click plays or pauses |
| Subtitles | A <name>.vtt or <name>.srt next to the original in its .info folder is loaded as a caption track and shown at once (SRT is converted to VTT first). “Load Subtitles” in the viewer’s right-click menu picks an .srt or .vtt and copies it there as <name>.<ext> | Seen | Same files, .vtt first, read by falcon’s own parser (subtitles.rs: SubRip and WebVTT timings, cue settings, NOTE/STYLE blocks skipped, tags such as <i>, <v …> and {\an8} stripped) and drawn as white lines on dark bands over the bottom of the picture. “Load Subtitles…” in the right-click menu copies the chosen file into the item’s folder the same way and shows it. No menu to switch tracks or turn them off |
| Leaving | The position is saved and the player destroyed | Seen | The player stops and is dropped when the viewer closes or moves to another item, or another library is shown |
| Timed comments | Stored in the item’s comments with duration (seconds), annotation and lastModified, kept sorted by time | Seen | The same records, written with Comment::at_time; the library keeps them sorted |
| Markers | A 4 px tick per comment on the progress bar at duration / length; hovering shows the text | Seen | Same, with the time and text on hover. Clicking a tick seeks there; right-click offers Edit and Delete |
| Adding | The note button or N pauses and asks for the text in a dialog at the bottom of the window; an empty text adds nothing | Seen | The comment button or N pauses and opens a box above the control bar (Save, Cancel, ⌘↩, Esc); an empty text adds nothing |
| Inspector | Comments listed with their times; clicking one seeks the video there, editing seeks and pauses first, deleting offers an undo notice | Seen | Listed with their times; clicking one opens the video at that moment; edit and delete as for region comments (⌘Z undoes) |
| Frame as thumbnail | “Set current frame as thumbnail” in the viewer’s right-click menu (player.thumbnail.set, unbound) sets thumbnailAt to the current second and regenerates the video thumbnail from there: a 480 px wide canvas drawing saved as a JPEG at quality 1.0 under the usual <name>_thumbnail.png name, then the palette. customThumbnail is not set | Seen | Same: the frame at the playhead, decoded at full size by an image generator on the player’s asset (Video::current_frame), drawn 480 wide and written as JPEG bytes to _thumbnail.png (Library::set_video_thumbnail), the palette recomputed and thumbnailAt saved |
| Copy frame | ⇧C or the menu copies the frame (drawn at the item’s recorded size) to the clipboard as an image | Seen | ⇧C or the menu puts the full-size frame on the clipboard |
| Save frame | ⇧S or the menu adds the frame to the library as a new item named <name> - <seconds to 2 decimals>, a JPEG at 0.95, with the video’s URL and folders and a modification time just after the video’s | Seen | Same name, JPEG at 95, URL and folders, through the normal import (so it shows in the import progress). The modification time is the import’s own. Nothing is saved to disk outside the library |
GIF and animated images
GIFs, animated WebPs and animated PNGs (APNG) play in the image viewer, every frame, looping. A player in crates/falcon-app/src/anim.rs decodes them on a background thread and keeps only a window of frames around the playhead, so a GIF of thousands of frames opens at once and uses about as much memory as a short one: a GIF’s blocks are walked once to find each frame’s offset and delay without decompressing anything, and each frame is then read from the file, decompressed and composited when playback needs it, with snapshots of the canvas every few frames for seeking. WebP and APNG frame counts and delays come from their chunk headers, and the image crate decodes their frames in order. Still WebPs and PNGs take the image viewer’s usual path. The player bar is crates/falcon-app/src/ui/viewer/gif.rs.
What Eagle does, from the GIF branch of the viewer in index.html, the gifViewer code in app.bundle.js, gif-viewer/ and js/default-preferences.js:
| Question | Eagle | Seen or inferred | falcon |
|---|---|---|---|
| Formats | GIF in its own viewer; WebP and AVIF marked animated at import, and APNG, play in the image viewer through Chromium’s <img> | Seen | GIF, animated WebP and APNG; on macOS also animated AVIF and HEIF sequences, whose frames and per-frame delays come from ImageIO (imageio::Animation). Elsewhere AVIF is a still |
| Timing | Chromium’s: a frame delay of 10 ms or less plays at 100 ms; the frame player itself uses 100 ms only for a delay of 0 | Seen | Chromium’s rule for both views. If a frame isn’t decoded in time, playback waits on the current one rather than skip |
| Frame player | A toolbar button (GIF only) toggles a frame-by-frame player until the viewer closes; the habits.gifViewer preference (off by default) always shows it | Seen | Same. The button is the film strip in the viewer bar; the preference is under Preview |
| Bar | Under the picture: play or pause, previous and next frame, current frame (zero-padded to the total’s digits) / total, a progress bar, the speed menu and a more menu | Seen | Same layout |
| Play and pause | Space, the button, or a short click on the picture | Seen | Same; the player keys are read before the viewer’s, so Space doesn’t close it while the player shows. Without the player, Space closes the viewer as for any image (in Eagle it does nothing on a GIF) |
| Frame steps | [ and ] (the player.prev1frame and player.next1frame keys) and the buttons step one frame and pause; scrolling over the progress bar steps too, up being forward | Seen | Same, plus ⇧[ and ⇧] for ten frames. The keys are the rebindable Player keys |
| Progress bar | Click or drag to scrub (pausing while dragging, then resuming); hovering shows that frame’s thumbnail with its number | Seen | Same; thumbnails are decoded on demand at 160 px |
| Loop range | Handles at both ends of the bar; dragging one moves the picture with it and, when dropped, sets that end of the range at the frame shown. Playback then repeats the range, and clicks outside it are ignored. “Reset range” in the more menu (or a right-click on the bar) clears it and goes to the first frame | Seen | Same |
| Speeds | 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 3, 4 and 8× | Seen | Same list, from the menu or ⇧. and ⇧, (the Player speed keys) |
| Set as thumbnail | In the more menu: the current frame, its short side brought down to 480 px, is written as a PNG to <name>_thumbnail.png and the palette is computed again. Nothing else changes (no customThumbnail, same width and height) | Seen | Same (Library::set_frame_thumbnail); see Thumbnails |
| Comments | Region comments work on GIFs as on other images | Seen | Same, in both views |
| Grid | Hovering a GIF, or an animated WebP or AVIF, card plays it in place (not with ⌥ held at the time); the habits.alwaysPlayGIF preference (off by default) plays them all. The hover preview loads the original, so a GIF plays there too | Seen | Same for GIF, animated WebP and (on macOS) animated AVIF; cards decode at their own size, at most 24 at once, a WebP found to be a still is not tried again, and a card whose original is still in iCloud stays still |
PDF viewer
Opening a PDF shows it in a reader instead of the image viewer: the pages in one continuous vertical column, with zoom, page navigation, find, text selection and a thumbnail/outline sidebar. Pages are drawn by macOS’s PDFKit through the falcon-pdf crate (crates/falcon-pdf), which keeps every Objective-C call in one module and hands the app RGBA pictures. The reader is in crates/falcon-app/src/ui/viewer/pdf.rs.
The file is opened, rendered, searched and selected only on a worker thread, never on the UI thread, so a PDF whose contents are still in iCloud (or a 1,000-page book) can’t freeze the window. While it opens the viewer says “Opening…”, or “Downloading from iCloud…” when the file is dataless (its SF_DATALESS flag is set, read without starting a download; pdf_view::icloud_only is the one place to switch to a shared iCloud helper). A file PDFKit can’t read shows a line saying why; a password-protected one asks for its password (see below). Off macOS the image viewer shows the preview instead.
Rendering: the UI asks the worker for the pages in view and one on either side, at the screen’s pixel density rounded up to steps of about 4%, so pages are never blurry at rest. Each page picture is capped at 16 MP (pdf.js’s own canvas cap), so past roughly 400% on a Retina screen a page is shown enlarged rather than re-rendered sharper. Pictures live as textures in an LRU cache of 128 MP for pages and 16 MP for thumbnails. While the zoom is moving, pages keep their old picture scaled; 160 ms after it stops they are rendered again at the new scale.
What Eagle does, from app/pdf-viewer/ (pdf.js 3.1.81 with Eagle’s edits in web/viewer.html and web/viewer.js) and the detail view in app.bundle.js and index.html:
| Question | Eagle | Seen or inferred | falcon |
|---|---|---|---|
| Engine | pdf.js in an iframe (pdf-viewer/web/viewer.html?path=…&locale=…&theme=…), the same in the main window and the preview window | Seen | PDFKit, drawn by egui |
| Toolbar | pdf.js’s: sidebar toggle, find, previous/next page, page number and count, zoom out/in and the zoom menu, print and the Tools menu. Eagle hides Open, Download/Save and the annotation editor buttons (style="display: none") | Seen | Sidebar toggle, find, previous/next page, page number field and count, zoom out, zoom menu, zoom in, and a Tools menu at the right end |
| Tools menu | Kept: presentation mode, first/last page, rotate clockwise and anticlockwise, text-select and hand tools, page/vertical/horizontal/wrapped scrolling, spreads (none/odd/even), document properties | Seen | Tools: Print… (⌘P: PDFKit’s printOperationForPrintInfo:scalingMode:autoRotate: with the system print panel, run on the main thread from a fresh copy of the document, unlocked with the password used to open it; Eagle shows Print in the toolbar itself), Presentation Mode (falcon’s presentation mode, see Viewer layout), Go to First Page, Go to Last Page, Rotate Clockwise and Counterclockwise (view only, as pdf.js: every page turns a quarter, pictures are drawn again turned, find hits and selections follow, the current page stays), Text Selection Tool and Hand Tool (drag to pan, with the grab cursor), Page, Vertical, Horizontal and Wrapped Scrolling, No, Odd and Even Spreads (pdf_view::arrange) and Document Properties…: spreads put two pages side by side (1–2, 3–4 or 1, 2–3, …) and Page Fit and Page Width fit the whole spread; horizontal puts every page in one row; wrapped fills rows as wide as the view; page scrolling shows one page or spread, turned by n/p, the thumbnails or the wheel once the page has scrolled to its end. n and p move a spread at a time |
| Layout | pdf.js defaults: vertical scrolling, no spreads, pages centred on the viewer background | Seen | Same by default: one column, 10 pt between pages, centred; the Tools menu changes it |
| Zoom | pdf.js’s menu: Automatic (default), Actual Size, Page Fit, Page Width, 50–400%; zoom steps ×1.1 between 10% and 1000%. Automatic is page width, page fit for landscape pages, never above 125%; 100% shows a point as 4/3 CSS pixels | Seen | Same choices, steps, limits and meaning of 100%; pinch or ⌘-scroll zooms around the pointer |
| Sidebar | pdf.js’s thumbnails, outline, attachments and layers, hidden on open unless the document or the view history asks for it (sidebarViewOnLoad −1) | Seen | Hidden on open; the sidebar button shows thumbnails, and its right-click menu switches to the outline, the attachments or the layers (the last two only when the document has some, as pdf.js disables their buttons). The outline highlights the entry for the current page (the last one pointing at it, else at the nearest earlier page; pdf_view::current_outline) and scrolls to it when it changes; pdf.js does this with its Find Current Outline Item button. Attachments lists the embedded files in pdf.js’s order (the catalog’s EmbeddedFiles name tree, then the pages’ file attachment annotations) with their sizes and descriptions; a click saves one where you choose. Layers lists the optional content groups with a checkbox each, starting from the default configuration for every document opened; a switch redraws the pages and thumbnails, and Reset Layers returns to the default (pdf.js does this on a double-click of its Layers button). With an /Order the list is its tree, as pdf.js draws it (falcon_pdf::layer_tree, read by Document::layer_tree): nested arrays indent, an array starting with a string is a heading with no checkbox (PDFDocEncoding and UTF-16 decoded, encrypted files too), an array after a group nests under it, order entries that aren’t groups are skipped, nesting past 10 levels is dropped (which also ends a cycle), and groups the order leaves out come last under an italic “Additional layers” heading. Switching a group never changes the groups nested under it, as in pdf.js. Without an /Order the list is flat in /OCGs order. PDFKit has no API for attachments or layers, so falcon_pdf::Document::attachments, attachment_data and layers read the document’s objects through Core Graphics’ CGPDF functions (documentRef), on the reader’s worker. PDFKit draws only a document’s default configuration, so Document::set_layers gives it another: lopdf finds the objects in the file, and an incremental update appended to an in-memory copy of its bytes replaces the configuration with exactly the chosen ON and OFF lists (falcon-pdf/src/ocg.rs). The default choice draws the original document. Text, search and links stay the original document’s. Printing shows the switched layers: the reader hands falcon_pdf::print the current choice, and the print call (on the main thread, where it opens the file afresh since a Document stays on its thread) draws the same in-memory update; the file on disk is never written. A document whose layers can’t be switched (lopdf can’t read it, or it is encrypted with strings beside a direct /OCProperties) greys out the checkboxes and says so |
| Find | pdf.js’s find bar (⌘F) with Highlight All, Match Case, Match Diacritics and Whole Words checkboxes, all off by default; ↩ and ⇧↩ or ⌘G and ⇧⌘G step through matches and wrap; the count shows “n of m” | Seen | ⌘F opens a find bar under the toolbar: query, previous/next, Match Case (Aa), Whole Words (Word) and Match Diacritics (é), all off by default, and “n of m”. All matches are always highlighted (as with Highlight All on), the current one stronger; a new search starts from the current page. Diacritics are ignored unless Match Diacritics is on (PDFKit’s diacritic-insensitive search); whole words keeps the matches with no letter, digit or underscore touching either end in the page’s text (falcon_pdf::is_whole_word) |
| Text | pdf.js’s text layer (textLayerMode 1): drag to select, ⌘C copies | Seen | Drag on a page to select its text (within that page), ⌘C copies, a click clears |
| Links | pdf.js’s link layer: internal links go to their page; clicks on external links open in the default browser (shell.openExternal) | Seen | PDFKit’s link annotations (Document::links: the annotation’s own URL or destination, else its URL or GoTo action), read on the worker with each page’s first picture. Over a link the pointer becomes a hand and shows the address or “Page n”; a click goes to the page (and the spot it names, in vertical and wrapped scrolling) or opens a web or mail address in the default browser. Other kinds of address (files, scripts) are not opened. Links follow rotation |
| Keys | pdf.js’s: n/j and p/k turn pages, Home/End and ⌘↑/⌘↓ go to the ends, ⌘+/⌘−/⌘0 zoom, ⌘F and ⌘G find, r and ⇧R rotate. Eagle’s page adds a keyup listener that posts Esc, ← and → to the app, which close the viewer and move to the previous or next item | Seen | n/j, p/k, Home/End, ⌘↑/⌘↓, ↑/↓ (scroll a line), Page Up/Down, ⌘+/⌘−/⌘0, ⌘F, ⌘G/⇧⌘G, r and ⇧R (rotate), s and h (text and hand tools). ← and → move between items and Esc closes, as in Eagle; Esc closes the find bar first when it is open, and ← → stay in the find field while it has focus. Space closes the viewer as for images (in Eagle it scrolls once the iframe has focus) |
| Remembered | pdf.js’s view history in local storage (pdfjs.history, 20 files by fingerprint): page, zoom, scroll position, rotation, sidebar and scroll/spread modes, restored on open (viewOnLoad 0) | Seen | The last page, zoom, rotation and scroll and spread modes of the 20 most recent PDFs, by item id, saved with the app’s state and restored on open |
| Theme | theme in the URL: “light” picks pdf.js’s light theme, anything else its dark one, which only recolours the frame (body #2A2A2E); pages stay white, never inverted | Seen | Pages are white on the viewer’s background colour of the current theme |
| Large files | pdf.js defaults: range requests and streaming on, auto-fetch on (disableRange, disableStream, disableAutoFetch all false); renders the visible pages and the next one, keeping at least 10 page canvases | Seen (settings); inferred (that ranges matter little for a local file URL) | PDFKit reads the file lazily; the visible pages and one either side are rendered, in a pixel-budgeted cache |
| Password | pdf.js’s password dialog, asked again after a wrong password | Seen | A prompt in the middle of the reader with a password field and Open; a wrong password says so and asks again. The worker tries each password with PDFKit’s unlockWithPassword (Document::open_with_password); nothing is stored |
| Document properties | Tools ▸ Document Properties: file name and size, title, author, subject, keywords, creation and modification dates, creator, producer, PDF version, page count, page size (with the paper name and orientation) and fast web view | Seen | The same fields from PDFKit’s document attributes and version (Document::info), the item’s name and size, the current page’s size in inches with Letter, Legal, Tabloid, A4 or A3 and the orientation (pdf_view::page_size_text), fast web view from the /Linearized dictionary at the start of the file, and whether it has a password |
| Comments | No region comments on PDFs: mousedown on the image is ignored for pdf | Seen | Same: the reader takes no comments |
Web page viewer
What Eagle does, from the url|html|mhtml branch of the detail view in index.html and getURLSrc in app.bundle.js:
| Part | Eagle | Status | Falcon |
|---|---|---|---|
| Engine | An Electron <web-view> filling the viewer, loading a bookmark’s stored url (YouTube and Vimeo bookmarks load their embed player) or the local file’s address for HTML and MHTML | Seen | On macOS a WKWebView (webview.rs), created through the Objective-C runtime and added over the window’s content view, placed over the viewer’s area every frame in AppKit points (following panel resizes, egui’s zoom and the display’s scale). A frame that doesn’t draw a web item hides it and blanks the page, so sound stops when the viewer closes or moves on; an egui menu, popup, window or modal over its area hides it until it closes. Hiding gives the keyboard back to egui. Files dropped on the page go to the import, not to the page. YouTube and Vimeo bookmarks load the page itself, not the embed player |
| Which page | .url bookmarks | Seen | .url loads the item’s URL, else the address read from the file (weblink::read); only http and https addresses load, other links (files, scripts, app schemes) show a still. HTML loads from its file with read access to its own folder, and fetches what it references from the network as a browser would. MHTML loads from its unpacked folder (below) and fetches nothing. A .webloc is a plain item with a QuickLook thumbnail, as in Eagle |
| MHTML | Rendered by Chromium | Seen | WebKit on macOS doesn’t read MHTML, so falcon unpacks it with its own reader (falcon_library::mhtml): the MIME parts (folded headers, nested multiparts, base64, quoted-printable, encoded words, each text part’s charset) become files in a folder of their own under <temp>/falcon-mhtml/, the root page as index.html and the others under names falcon makes, never paths from the archive. References in the HTML and CSS (by Content-Location, including full URLs with queries, relative paths read against the part’s location or a <base>, and cid:) are rewritten to those files, keeping fragments. The page then loads live in the same WKWebView, with read access to that folder only, and with JavaScript as for other pages. Every unpacked page carries a Content Security Policy allowing only file:, data: and blob: loads, so nothing the archive doesn’t hold is fetched from the network (following a link still navigates). Unpacking runs on a worker (“Loading…” meanwhile) and is capped at 256 MB of decoded parts and 20 000 parts. The folder is removed when the viewer leaves the item, when a preview window closes and when falcon quits; folders a crashed falcon left are removed after 12 hours, the next time an archive is opened (web_archive.rs). The bar’s address is the one stored on the item, else the page’s saved address (Snapshot-Content-Location), never the temporary folder; Open in Default App opens the archive itself. An archive that can’t be read (not MIME, too large, damaged) is a still with a line saying so. |
| Bar | The web view’s controls: title, address, back, forward, reload, stop while loading, copy URL and open in the default browser (webviewControl in app.bundle.js) | Seen | The viewer bar with back, forward, reload (stop while loading), Copy URL and Open in Browser at the right, the page’s title and its current address, muted, at the left. The still mode keeps Copy URL and Open in Browser (or Open in Default App) |
| Elsewhere | — | — | Off macOS, or without WebKit, every web item is a still |
| Keyboard | — | — | While the page has the keyboard (after a click on it), egui’s keys such as Esc and the arrows go to the page; a click outside the page gives them back |
3D model viewer
Opening an obj, stl, ply, glb, 3mf, fbx, dae, 3ds or ifc item (Eagle’s model formats; a .gltf is a plain item with a QuickLook thumbnail, as in Eagle) shows the model in an interactive viewer in the central panel, like the other viewers, with the shared bar (panels, back, counter, open, previous and next; Esc and ← → work as usual). The readers and geometry are crates/falcon-library/src/model3d/ (one file per format, geom.rs for normals, edges, details and lighting, math.rs for vectors and matrices). The camera maths are crates/falcon-app/src/logic/viewer3d.rs, the viewer’s state and vertex data crates/falcon-app/src/model3d/, the wgpu drawing model3d/gpu.rs and the viewer itself ui/viewer/model.rs. An ifc file is read through web-ifc when falcon is built with it (see IFC below); without it, an ifc file with no tessellated geometry keeps its QuickLook still, as does any model that fails to read. Without the wgpu renderer every 3D item shows its still.
FBX
FBX files, binary and ASCII, are read by model3d/fbx.rs with the ufbx C library (MIT, https://github.com/ufbx/ufbx) through the ufbx crate, which compiles the vendored C source with cc.
- Every node with a mesh is drawn, with its world transform applied. Each material the node uses becomes one mesh in the tree, named after the node.
- Polygons are split into triangles with ufbx’s triangulation, which handles concave faces. Normals come from the file, or ufbx generates them when the file has none.
- Each material gives its diffuse colour (times the diffuse factor), its
Opacityproperty, and its diffuse (or PBR base colour) texture: the picture embedded in the file’sVideocontent, else the file its relative or absolute name points to, looked up next to the model (see Textures below). UVs come from the first UV set, with the texture’s UV transform; wrap modes are not read, since every texture repeats, as in Eagle. - The scene is converted to the viewer’s convention: right-handed, Y up. Sizes are in centimetres, the FBX default unit. The file’s
UnitScaleFactoris applied, so a model saved in metres still reads in centimetres, and a Z-up file stands upright. - Skinned meshes are shown in their bind pose. Blend shapes, animation, cameras, lights, NURBS, vertex colours and external files other than textures are skipped.
- A broken file is an error, never a crash. ufbx’s memory is capped (1 GiB while parsing, 2 GiB for the scene), node nesting is limited to 256 levels and output to 50 million vertices. The file is always parsed as FBX, so an OBJ renamed to .fbx is refused. An FBX without triangles is an error, like the other formats.
- The thumbnail at import, the Details panel, the mesh tree, measuring, snapshots and export work as for the other formats.
- The tests build a small box FBX in both encodings in code (ufbx can’t write files) and check the triangles, material, transform, generated normals, unit scale, Z-up conversion, empty scenes and truncated or corrupt files.
COLLADA and 3DS
model3d/dae.rs reads COLLADA 1.4.1 and 1.5 with roxmltree. It reads the meshes of library_geometries: triangles, polylist, polygons, tristrips and trifans, with polygons fan-triangulated and the holes of ph ignored. Sources go through their accessors (count, offset, stride, named params), giving positions, normals and vertex colours. The visual scene’s node tree is composed into world transforms from matrix, translate, rotate, scale and lookat, following instance_geometry, instance_node and instance_controller. A controller shows its skin’s or morph’s base mesh in the bind pose, after the skin’s bind shape matrix. The root asset’s up_axis and unit are turned into falcon’s Y-up metres the way three.js’s COLLADA loader does: Z-up turns -90° about X, X-up turns +X to up, and the unit scales. A mirroring transform flips the winding so faces stay outward. Each primitive’s material comes through the instance’s bind_material. Its colour is the effect’s diffuse colour (emission for constant). When the diffuse is a texture (through the 1.4.1 sampler and surface params, 1.5’s instance_image, or an image id used directly; a file next to the model or 1.5’s embedded hex data), that texture is drawn with the TEXCOORD set the instance binds through bind_vertex_input (else the first), (the sampler’s wrap_s and wrap_t are ignored); the material colour, used where the picture can’t be read, is the picture’s average colour, else the default grey. The reader skips animation, skinning weights, morph targets, cameras, lights, skew, lines and transparency. Without normals in the file the viewer shades flat, or smooth when Smooth shading is chosen.
model3d/threeds.rs reads binary 3DS chunks: the editor’s named triangle meshes (vertices, faces, per-material face groups, smoothing groups) and its materials (name, diffuse colour in the 0x0010 to 0x0013 forms, the gamma-corrected one preferred, transparency, and the diffuse texture map 0xA200 with its file name, scale, offset and rotation 0xA354 to 0xA35C, which are baked into the mapping coordinates 0x4140; the picture’s average colour replaces the diffuse colour for when it can’t be drawn). Each object gives one mesh per material, plus one without a material for ungrouped faces. With smoothing groups, every corner’s normal averages the faces meeting at that place that share a smoothing bit, and group 0 stays flat. Without smoothing groups the faces shade flat. The vertices are already in world space, so the local axis matrix (0x4160) is not applied, and coordinates are kept as stored (Z up, like 3MF and STL; the viewer’s up setting turns them). The reader skips the keyframer (0xB000), lights, cameras and the master scale. Every count is checked against the bytes left in its chunk before anything is allocated.
Both readers return an error for a truncated or corrupt file instead of panicking. Tests cut a file at every byte and damage bytes one at a time. They also cap node visits, nesting depth and triangle totals (COLLADA) and distinct smoothing masks per vertex (3DS).
Textures
glb, obj, fbx, dae and 3ds draw their diffuse (base colour) texture in the viewer, in snapshots and in import thumbnails, as Eagle’s viewer does. 3mf, ply and stl have no textures, in falcon or in Eagle’s viewer. Each mesh carries its texture coordinates (in picture space, v down) and a texture (model3d::texture): the decoded picture, a tint and an optional alpha cutoff. Textures always repeat.
- Where pictures come from: the GLB’s binary chunk, a data URI or an image file a GLB names, an MTL
map_Kd, FBX embedded media or its file name, COLLADAlibrary_images, and the 3DS texture map’s file name. Files are looked up only next to the model or in a folder below it: a relative path that stays inside is tried as written (an MTL’s maps first next to the MTL), and every name, including an absolute path or one climbing out with.., is also tried as its bare file name next to the model. Nothing outside the model’s folder is read. A missing or unreadable picture leaves the material colour, as before. - Memory: a picture is decoded to at most 4096 pixels on its longer side (scaled down by ImageIO while decoding on macOS, so a 10000 × 10000 picture never sits in memory at full size) and one model’s pictures together take at most 64 MB of RGBA; a picture that doesn’t fit what is left is halved until it does, or skipped. The same file or embedded bytes are decoded once. The viewer lets go of the model, its pictures and their GPU textures as soon as a frame goes by without drawing it (another item, a closed viewer).
- Colour: glTF multiplies the picture by its base colour factor; the other formats draw the picture in place of the material colour (Online3DViewer’s
multiplyDiffuseMapis set only for glTF). Vertex colours multiply in as well. The multiply is done in linear light and the result lit like an untextured face. - Placement: glTF
KHR_texture_transform, MTL-oand-s, FBX UV transforms, and 3DS scale, offset and rotation. Eagle’s viewer always repeats textures, whatever wrap mode the file names (glTF samplers, MTL-clamp, FBX wrap modes, COLLADAwrap_s/wrap_t, 3DS tiling flags); falcon reads none of them and repeats too, in the viewer, snapshots and thumbnails. glTF’sMASKalpha mode cuts out pixels under its cutoff; Above the cutoff,MASKpixels blend with their alpha, as they do in Eagle’s viewer. - The GPU uploads each picture as an sRGB texture with mipmaps made on the CPU in linear light and samples it trilinearly with 16× anisotropic filtering. The thumbnail rasterizer samples it bilinearly in linear light at perspective-correct coordinates.
- Not drawn: specular and metal-roughness maps. Exports keep geometry and material colours only; textures are not written.
Normal, bump and emissive maps and transparency
Falcon draws what Eagle’s viewer draws for each format, which is less than some formats can carry. Online 3D Viewer’s own readers handle glb, obj and 3ds. fbx and dae go through three.js’s loaders, and only colour, opacity, alpha test, diffuse, normal and bump maps survive the conversion back.
| Format | Normal map | Bump map | Emissive | See-through when |
|---|---|---|---|---|
| glb | normalTexture | none | emissiveFactor times emissiveTexture | alpha mode is BLEND or MASK |
| obj | none | map_bump, bump | none (Ke and map_Ke are skipped) | d or Tr isn’t opaque (map_d is skipped) |
| fbx | NormalMap | Bump | none | Opacity is below 1, or a texture is on TransparentColor or TransparencyFactor |
| dae | the bump slot, directly or in extra | none | none | transparent holds a texture, or transparent and transparency give an opacity below 1, in all four opaque modes |
| 3ds | none | none | none | transparency is above 0 |
| ifc | none | none | none | the style’s transparency is above 0 |
3mf colour alpha stays opaque, as in Eagle’s viewer: its loader sets the opacity without marking the material transparent. A glTF material with KHR_materials_pbrSpecularGlossiness has no normal or emissive map and isn’t see-through, also as in Eagle.
- Coordinates: a mesh has one set, which every map reads. Its transform comes from the first of the base colour, normal, bump and emissive maps that the material has, as three.js picks one
uvTransform. - Normals: no tangents are stored. The frame comes from screen derivatives of position and coordinates in the shader, and from the triangle’s edges in the thumbnail rasterizer (
geom::normal_mapped). Green points up the picture. A normal map wins over a bump map. - Bumps: the height is in the red channel, in the model’s units, so the same map looks stronger on a small model, as in Eagle (
geom::bumped). MTL’s-bmis ignored, also as in Eagle. - Colour: normal, bump and emissive maps are uploaded as stored, with plain mipmaps, and emissive light is added to the lit colour in display values, because Eagle’s viewer applies no colour conversion to them.
- Blending: see-through meshes draw after everything else, including lines, farthest first by the middle of their bounds; each draws its back faces and then its front faces, writing depth as it goes. Alpha is the opacity times the picture’s alpha. This is three.js’s order, so it shares its faults: parts that interleave can hide each other.
- Memory: maps count towards the same 64 MB per model, and each may take only half of what is left, so they can’t starve the pictures of materials read later. Missing or bad maps leave the surface as if the file had none. Thumbnails and snapshots draw all of this in the same order.
IFC
IFC geometry comes from web-ifc 0.0.78 (MPL-2.0, https://github.com/ThatOpen/engine_web-ifc), the engine Eagle’s Online 3D Viewer uses (Eagle 4.0.0 ships web-ifc 0.0.34 as WebAssembly; falcon builds a newer release natively). scripts/fetch-web-ifc.sh downloads web-ifc and the header-only libraries it builds against (glm, tinynurbs, fast_float, earcut.hpp, CDT, spdlog, stduuid, unordered_dense; all MIT, BSD, ISC, Apache or MPL) as GitHub tarballs at pinned tags or commits, checks each against a pinned SHA-256 and unpacks them into target/web-ifc (or $WEB_IFC_DIR); nothing is committed and no CMake is needed. crates/falcon-ifc/build.rs then compiles web-ifc’s sources, unmodified, as C++20 with the cc crate (twelve seconds from a clean target/ on an 18-core machine; cc compiles them again only when the sources or worker.cpp change), and links them with falcon’s own crates/falcon-ifc/cpp/worker.cpp into one executable of 1.2 MB, falcon-ifc-worker, in the build’s output directory. On macOS the worker is built for 13.3 or later, since web-ifc formats numbers with std::format, which macOS’s C++ library has only from 13.3; on older systems it can’t start, and IFC files fall back to falcon’s own reader. Nothing of web-ifc is linked into falcon’s binaries. $FALCON_WEB_IFC_DIR points the build at another copy of the sources. Without them no worker is built, falcon_ifc::available() is false, and falcon reads only the tessellated part of IFC with its own reader, as described at the end of this section. scripts/bundle.sh copies the worker into Falcon.app/Contents/MacOS (falcon looks beside its own executable first, then where the build put it) and the licence of each component into Contents/Resources/web-ifc.
The file is read as Online 3D Viewer reads it (OpenModel with COORDINATE_TO_ORIGIN, then LoadAllGeometry): every product web-ifc knows except IfcOpeningElement, IfcOpeningStandardCase and IfcSpace, each placed geometry with its colour (styled item, material or web-ifc’s white default) and its placement matrix, lengths in metres, Y up, and the model moved so that its first vertex sits at the origin. web-ifc meshes extruded, tapered, revolved and swept-disk solids with every parametric profile (rectangle, rounded and hollow rectangle, circle and hollow circle, ellipse, trapezium, I, L, U, T, C and Z shapes, arbitrary closed profiles with polyline, composite, trimmed and indexed curves, profiles with voids), fixed-reference and surface-curve sweeps, IFC 4.3 sectioned solids, boolean results and clipping results (half-spaces and polygonal bounded half-spaces), openings cut from their hosts through IfcRelVoidsElement, CSG primitives (IfcBlock, IfcRectangularPyramid, IfcRightCircularCylinder, IfcRightCircularCone, IfcSphere), advanced breps with planar, cylindrical, revolved, linearly extruded and b-spline faces (rational or not), mapped items and every tessellated form. Curves (axes, curve sets) are not drawn, as in Eagle. Circles use web-ifc’s default of 12 segments per turn. Not meshed by this web-ifc release: IfcSectionedSpine, IfcBoxedHalfSpace, IfcRevolvedAreaSolidTapered, IfcMirroredProfileDef, and advanced brep faces on toroidal, spherical or conical surfaces; a product built only from those counts as skipped with the type named under Not Read.
web-ifc runs in a process of its own for each read, because a malformed file can make C++ overflow its stack, spin forever or read out of bounds, and no thread survives those. falcon writes the limits and the file to the worker’s stdin and reads the geometry from its stdout as it comes (crates/falcon-ifc/src/worker.rs; the protocol is at the top of worker.cpp). The worker reads on a thread with a 256 MB stack, catches C++ exceptions (a product web-ifc can’t process is left out while the rest are read), and exits at once on a fatal signal, so a crash leaves no crash report. It ends itself past 120 seconds or 16 GB of memory, which also covers a falcon that has gone, and falcon kills it two seconds after that. A crash, a hang or a runaway allocation therefore reaches falcon only as an error from falcon_ifc::read, and the next file reads normally. One file is read at a time, and reading stops past 20 million triangles (“IFC: the model is too large”). Files over 256 MB are not read. A file with references that dangle, form a cycle or chain deeper than 10,000 gets 30 seconds instead of 120: web-ifc reads most such files fine but can spin on some. Starting the worker costs a few milliseconds per file. The file is also parsed by falcon’s own STEP parser first (below); a file that parser refuses is not handed to web-ifc. A file web-ifc can’t read, whatever the reason, falls back to falcon’s own reader, and the inspector’s 3D Preview row then says so (“IFC: web-ifc could not read this file”) above what that reader found. Meshes are grouped by product type and colour, so the tree lists IfcWall, IfcSlab, IfcWindow and can hide each kind. The bridge is crates/falcon-library/src/model3d/ifc_native.rs.
For the inspector, a product counts as shown when web-ifc drew at least one triangle for it. One that came out empty counts as skipped only when it has a drawable shape representation (Body, Facetation or any other except Axis, FootPrint, Box, Annotation and similar), and Not Read lists that shape’s geometry types (through mapped items). Products cut away whole by their openings (frame placeholders filled by their opening, for instance), products without a shape, and openings, spaces, zones, virtual elements, annotations and grids are neither.
Across 117 sample files (none committed), no file falls back and every product Eagle’s own engine draws is drawn. The set is the IFC 2x3, IFC 4 and IFC 4.3 certification scenes and the IFC4.x specification models from buildingSMART (81 files), eight IFC 2x3 Coordination View 2.0 files of the Schependomlaan project and the 28 files in web-ifc’s own tests/ifcfiles/public. They were also run through Eagle’s web-ifc.wasm (0.0.34) under Node: it drew 56,458 products and falcon drew all of them and 1,257 more (IFC 4.3 alignments and advanced breps, among others), and the one file that aborts Eagle’s engine reads here. In 60 files the two agree exactly; elsewhere triangle counts differ because the two releases mesh and cut differently. Empty products are curves (IfcGeometricCurveSet, IfcGeometricSet), which neither draws, and the five files that draw nothing hold only curves or geometry on type objects. The largest file, 69 MB, takes 2.6 s and 307 MB in the worker, for 1.6 million triangles; the 47 MB Schependomlaan model takes 0.9 s. FALCON_IFC_SAMPLES=<dir> cargo test --release -p falcon-library --test ifc_samples -- --nocapture prints a line per file, and FALCON_IFC_FUZZ_DIR=<dir> cargo test -p falcon-ifc --test isolation damaged_sample_files reads damaged copies of each file (71 files, 20 rounds each, survived).
Without web-ifc, IFC is read only as far as its tessellated geometry goes (model3d/ifc.rs, with the STEP parser in model3d/step.rs and ear clipping in model3d/earclip.rs). The STEP parser reads the header’s schema and the DATA; section’s #id = ENTITY(args); instances with nested lists, strings (with their \X2\ and similar escapes), enums, $, *, references and typed values, wherever the line breaks and comments fall; it works on tokens with an explicit stack and refuses lists nested more than 64 deep. Drawn: IfcTriangulatedFaceSet (an IfcCartesianPointList3D with its coordinate index, point index and normals), IfcPolygonalFaceSet (indexed faces, with voids), faceted breps (IfcFacetedBrep and IfcFacetedBrepWithVoids, their closed or open shells, IfcFace with IfcFaceOuterBound / IfcFaceBound poly loops, holes included) and shell- or face-based surface models built from the same faces. Faces that are not triangles are cut by ear clipping on the plane they face most, holes joined to the outline by a bridge. Each product (anything with an IfcProductDefinitionShape, except openings, spaces, zones, virtual elements, annotations and grids) is placed through its Body shape representation (other drawable representations when Body has none), IfcMappedItem (up to 8 levels deep) and its IfcLocalPlacement chain. Lengths are converted to metres from the project’s IfcUnitAssignment, IFC’s Z up becomes the viewer’s Y up, and a model more than 10 km from the origin is moved to it. Colours come from an IfcStyledItem on the geometry, else from the product’s or its type’s material, else from a default per entity type. Swept solids, CSG and boolean results, b-spline surfaces and advanced breps are not read by it: a product with only such geometry counts as skipped, and a file with no tessellated geometry at all fails to read (“IFC: no tessellated geometry”), so it keeps its QuickLook still.
The item inspector lists, under Properties, for an ifc item: Products Shown, Products Skipped, Not Read (the unread geometry types with their counts, the three most common), Triangles and Schema, or 3D Preview with the reason when nothing can be drawn (“IFC: no 3D geometry” with web-ifc, “IFC: no tessellated geometry” without) (model3d/ifc_facts.rs, read on one background thread at a time and kept per item; ui/inspector/ifc.rs).
What Eagle does: it embeds the Online 3D Viewer in an iframe (index.html near line 1443, model-viewer/) and forwards Esc, ← and → to the main window. The rows below come from that viewer’s behaviour.
| Question | Eagle | Seen or inferred | falcon |
|---|---|---|---|
| Formats | fbx, obj, 3ds, 3mf, dae, ifc, ply, stl, glb | Seen | obj (with MTL colours and map_Kd textures), stl (ASCII and binary), ply (ASCII and binary, vertex colours), glb (positions, normals, indices, vertex colours, base colour and its texture), 3mf (meshes, components, base materials and colour groups), fbx (binary and ASCII, all mesh nodes, material colours and diffuse textures, unit and axis conversion; bind pose only), dae (COLLADA 1.4.1 and 1.5) and 3ds (see below) and ifc (every solid kind through web-ifc, openings cut; tessellated geometry only without it; see IFC above). The rest show their still |
| Opening view | Y up, the eye above, in front and to the left (direction (−1.5, 2, 3)), 45° field of view, fitted so the bounding sphere fills the shorter side | Seen | Same |
| Mouse | Left drag orbits (half a degree per pixel), ⇧ or right or middle drag pans (0.001 of the distance per pixel), ⌃ drag zooms, the wheel moves a tenth of the distance per step | Seen | Same, plus a trackpad pinch. Right click without dragging opens the item menu |
| Orbit | Fixed up vector by default, never past straight up or down; free orbit as an option; flip up vector; Y or Z up | Seen | Same, in the settings menu. Changing the up axis goes back to the opening view |
| Camera | Perspective or orthographic | Seen | Same; orthographic keeps the size seen at the centre |
| Fit | A toolbar button fits the model to the window | Seen | The fit button in the bar and ⌘0 (⌘= and ⌘− zoom a wheel step) |
| Lighting | Grey ambient and a grey directional light from the camera (0x888888 each) on Phong materials, default colour (200, 200, 200) | Seen | Same light strengths and default colour, both sides of a face lit, a faint highlight. The default colour can be changed |
| Background | Eagle forces the viewer’s dark mode, whose background is transparent, so Eagle’s own viewer background shows; a background colour can be picked | Seen | The viewer’s background by default; a custom colour in the settings menu |
| Edges | “Show Edges”, off by default, black, 1° angle threshold, with colour and threshold settings | Seen | Same: the edges where faces meet at more than the threshold, plus open edges, drawn over the faces. Colour and angle (0 to 90°) in the settings menu |
| Grid and axes | Not in Eagle’s build | Seen | Extra, off by default: a grid on the floor under the model (round steps, three radii out) and X, Y and Z axes in red, green and blue |
| Shading | Normals from the file, flat faces otherwise | Inferred | Auto (the same), Flat or Smooth (area-weighted normals over welded vertices) |
| Details | A sidebar with Vertices, Triangles, Size X/Y/Z, Volume (closed meshes only), Surface and the materials | Seen | A details panel over the scene with the same rows and the materials with their colours; Details in the bar hides it |
| Mesh tree | A navigator listing files, materials and meshes (hide, isolate, fit one) | Seen | Tree in the bar opens a panel over the left of the scene (ui/viewer/model_tools.rs): Files (the item’s file), Meshes (an eye to hide or show each, Fit Mesh to Window, Isolate; isolating the same mesh again shows all; double-click fits) and Materials (a click shows only the meshes with that material, again shows all). Hidden meshes leave the scene, the edges, picking, fitting, export and snapshots |
| Measure | A measure tool: click two points on the surface for their distance; for two faces also the angle and, when parallel, their distance | Seen | Measure in the bar: clicks pick points on the visible surface (a ray through the pointer, model3d::export::pick), a third starts over and a click off the model clears. The points and the line between them are drawn over the scene, with a readout of Distance, Angle between the faces and, for parallel faces, the distance between their planes (model3d::export::measure) |
| Export and snapshot | Export the model to other formats; Create Snapshot saves a picture of the view | Seen | The export button’s menu lists every writer, in Eagle’s order (export::Format::ALL): GLB, glTF (.gltf, buffer embedded as a data URI, colours stored linear as glTF wants), OFF (ASCII: an OFF line, <vertices> <faces> 0, x y z per vertex, 3 a b c per triangle with zero-based indices, meshes merged; positions and triangles only), OBJ (with an MTL of the material colours), PLY (binary, meshes merged, material colours per vertex) and binary STL, each of the visible meshes (model3d::export::write) and round-tripped through falcon’s readers in tests; Eagle’s 3DM is not built; the OFF reader (model3d::off) and the .gltf reader (gltf::parse_gltf) exist only for the round-trip tests, and falcon does not open .off or .gltf files, as Eagle doesn’t. The camera button saves a PNG of the view at its pixel size, drawn by the software rasterizer from the same camera (raster::render_view_lines): the faces, then the edges, grid and axes that are on, about a pixel wide in their own colours and hidden behind faces (Lines::shown reads them back from the scene’s line data), on the chosen background or transparent |
| Theme | The viewer’s dark mode setting (Eagle forces dark) | Seen | Theme in the settings menu: Auto (follows falcon), Light (white background, light panels) or Dark (Eagle’s dark grey, dark panels); a chosen background colour still wins |
| Settings kept | In the viewer’s cookies | Seen | For as long as falcon runs |
The model is read on a background thread; the viewer shows “Loading model…” meanwhile. Drawing goes through an egui_wgpu paint callback: the scene is drawn into the callback’s own 4× multisampled colour and depth textures (lines on top of slightly pushed-back faces), resolved, and copied into egui’s pass with premultiplied alpha. Textured meshes draw with a second pipeline (see Textures above). Material opacity is ignored. GPU tests (draws_a_cube_when_a_gpu_is_present, draws_a_textured_cube_when_a_gpu_is_present) render a cube, plain and textured, through the whole path when an adapter is available.
Duplicates
The sidebar’s Duplicates entry (after All Tags) opens a page that works like Eagle’s duplicate panel. Pick a method, Identical Files (byte-identical originals) or Similar Pictures (perceptual hash), and what to scan: All Items, the view the page was opened from (its items in grid order), or the items that were selected there (two or more). Scan runs on a background thread with a progress bar. Changing the method or the range clears the results.
Each group is a row of thumbnails under a checkbox (“N identical files” or “N similar pictures, the best copy picked”), and every group starts ticked. Each thumbnail shows its name, type, size, dimensions and, for similar pictures, its distance in bits from the first. The copy a merge keeps is outlined and marked “Kept”. For similar pictures falcon puts the best copy first and keeps it, as Eagle does: the most pixels, then for the same format the bigger file, else the better format (PNG, BMP, JPG, JPEG, WebP, AVIF, JXL, HEIC, HEIF, JFIF; merge::best_first). Clicking a similar picture keeps it instead, and for any group the thumbnail menu has Keep This Copy, Open, Open in Default App and Remove from Group. Remove from List drops a whole group. A group left with one item leaves the list. Double-clicking opens the viewer. The similarity slider sets how many of the 64 bits may differ (10 by default, right is stricter) and scans again. A line gives the number of picked files and the space a merge saves.
Merge Groups… opens a review of the ticked groups. For the chosen group it shows the kept item, then radio buttons for its File Name, Link and Note, picked from the group’s distinct values (the first by default), with Join all notes when there are several notes (joined by line breaks and cut to 4,096 characters, as in Eagle), and toggles for the group’s folders and tags, all on by default. Merge asks once more, then applies Library::merge_duplicates (crates/falcon-library/src/merge.rs). The kept item takes the chosen values and the group’s highest rating, and the others move to the Trash with their files untouched. A copy kept by one group is never trashed by another. The whole merge is one ⌘Z step: undo puts back the kept item’s name (its file renamed back), link, note, folders, tags and rating, and brings the others out of the Trash. A summary then gives the files moved to the Trash and the space saved, with Keep Merging (the merged groups have left the list) and Done (back to the start). The code is in crates/falcon-app/src/ui/grid/duplicates.rs, with the group rules in logic/.
The scan fills the hash cache item by item so it can report progress (content hashes only for files that share their size with another), then calls the library’s find_exact_duplicates_in or find_near_duplicates_in (see Import) on a copy of the library taken when the scan starts. Items outside the range never link two similar groups together. The cache is kept in memory for the session, keyed by item id and checked against the original’s size and mtime, so a second scan only hashes new or changed files. Nothing is written to the library until a merge.
What Eagle does, from js/directives/duplicate-scan-panel.html and .js, merge-editor.js and the import menu in app.bundle.js:
| Question | Eagle | Seen or inferred | falcon |
|---|---|---|---|
| Where | The import (+) menu’s Find Duplicates submenu: the whole library, the current list, or the selected items | Seen | A sidebar page; its range menu offers the same three, the list and selection being those of the view it was opened from |
| Methods | Two separate scans to choose from first: identical files and similar images, each with its own progress bar (current/total and a percentage) | Seen | Same two, chosen above the results, with the phase and done/total |
| Similarity | A slider (default 0.8) over a fingerprint weighting, re-grouping without rescanning | Seen | A bit threshold on the 64-bit pHash; changing it rescans, which is fast once hashes are cached |
| Results | Groups of thumbnails with name, type, size and dimensions; each group has a checkbox (all ticked) and a kept item (the first for identical files, the best copy for similar ones, which a click changes); Remove from list and, per item, Remove this item | Seen | Same; Keep This Copy also works for identical files |
| Action | Merge, after a review of each group’s name, link, note (or all notes joined), folders and tags, and a confirmation: the kept item takes them and the highest rating; the others get isDeleted (the Trash). A summary gives files deleted and space saved, with continue or exit | Seen | Same, as one undo step |
| Stored hashes | None in item JSON | Seen | None; an in-memory cache for the session |
Duplicate check on import
Every import from the app (drops, ⌘V, files passed to the app, the collect window) checks whether each file is already in the library, while the preference is on. The import thread stages the file without the library lock, takes the lock only to list items of the same size and extension, and compares the bytes with the lock released (import_check, see Import). A match is not committed: the staged copy is held in a queue and the thread goes on with the next file. When the batch has finished, the window takes the queue into a dialog; the thread never waits for the window. The code is in crates/falcon-app/src/import_dupes.rs (thread side and decisions, with tests) and ui/dialogs/duplicates.rs (the dialog).
The dialog, “Already in the library (N)”, shows the existing item’s thumbnail, name and first folder, the new file’s size and target folder, two choices, Use the existing item (the default) and Keep both, a “Do the same for all N files” box when more than one file is held, and Import and Don’t Import buttons. Return imports with the chosen option and Esc skips; with ⌘ held either answers for every file. A click outside doesn’t answer. Use the existing item puts the existing item in the import’s folders with their auto-tags and moves its date added to now (Library::use_existing_for_import); if that item has been deleted or trashed meanwhile, the file is imported instead. Keep both commits the held copy as a new item. Don’t Import drops it, removing the staged copy. Each answer is one undo step and shows a notice (“Used the existing items for 2 files”). Held files count as done in the progress bar’s “Added N”. Held files still waiting when their library is closed are dropped.
AI Search
AI Search finds pictures by describing them (“black cat”) or by showing a similar one, on the Mac. Eagle does this with a plugin and an outside service; falcon has it built in, and it stays off until the model is installed. Descriptions should be in English: the model was trained on English captions, and other languages work poorly or not at all.
The code is crates/falcon-clip (the model, its download, the index and the worker, so the rest of the workspace doesn’t rebuild the ML dependencies), crates/falcon-app/src/ai_search.rs (the engine handle, one library’s search, the Preferences pane) and crates/falcon-app/src/app/ai_search.rs (the impl App part).
| Part | Behaviour |
|---|---|
| Model | CLIP ViT-B/32 trained on LAION-2B (laion/CLIP-ViT-B-32-laion2B-s34B-b79K), declared MIT on its model card, run through candle on Metal, or on the CPU if Metal isn’t there or fails its first text. Only safetensors weights are read, never pickle |
| Download | Nothing is fetched until Preferences ▸ AI Search’s Download Model is clicked, next to the model’s name, size, licence and source. The download is pinned to one commit, sends no identifiers, can be cancelled, continues where it stopped and is checked against pinned SHA-256 hashes; the files are checked again each time they are loaded. Remove Model deletes the model and every library’s index. It is the only network use: images and queries never leave the Mac |
| Where things live | ai-search/ in falcon’s app data (~/Library/Application Support/Falcon/): model/<commit>/ and indexes/<hash of the library's path>.index. Nothing is written inside the .library folder, so libraries stay byte-identical |
| Index | One vector per item, read from its thumbnail, or from its original when it has no thumbnail. A worker thread reads eight pictures at a time and then waits as long as that took, so it uses at most half of the machine’s time; the library lock is only held to list the items, never while reading pictures or running the model. Records are appended as they are made, so indexing resumes after a restart. It follows the library: every two seconds it compares a fingerprint of the items, and every five minutes the thumbnail files too. A new item or a changed picture is read; an edit that leaves the picture alone isn’t. An item in the Trash keeps its vector and isn’t a result; deleting it for good forgets it. An item with nothing readable is counted as skipped and tried again only when it changes. Only the shown library is indexed |
| Progress | A thin bar under the toolbar button and its tooltip; Preferences ▸ AI Search also has the count, the device, Pause Indexing and Rebuild Index |
| Text search | The toolbar’s AI Search button (the sparkle, left of the funnel) makes the search box read descriptions, with a highlight on the button; another click goes back to keyword search. While the model isn’t ready the button opens Preferences ▸ AI Search |
| Similar images | Visual Match in an item’s menu (and find.filter.image, unbound by default, which uses the selected item or asks for a picture); the search box’s camera with Choose Image… and Paste Image; or a picture dropped on the search box. While one is shown the box says what it’s like and an X replaces the camera |
| Results | Shown through the normal grid, best first whatever the view’s sort, and limited by the open view and the filter bar. The title says “Search results (N)”. A click on the title, Clear Filter or opening another view (unless the filter is locked) ends the search. At most 500 results; a description’s results score at least 0.20 and within 0.08 of the best, and an image’s at least 0.55. These cut-offs were chosen from a small set of test pictures |
| Saved filters | The Saved menu’s Save This Filter… keeps the current search: a description as semantic.value, a Visual Match from an item as image.itemId. A search by a chosen, dropped or pasted picture isn’t saved, since the format only holds an item’s id, and the toast says so. Applying a saved filter restores its rules: a description goes in the search box, or stays on the filter bar’s AI Search chip if the filter has its own search text in the box, and a Visual Match shows as “Like |
| Rules that can’t apply yet | A restored rule runs only once the shown library is fully indexed (Preferences ▸ AI Search says “Indexed N of N items”), and then by itself. Until then the filter’s other rules apply, the AI Search chip shows the rule, and the end of the filter bar says “Can’t apply yet.” with the reason: turned off, the model isn’t installed, loading, indexing or paused. A rule whose item has left the library, or whose value isn’t text, also stays on the chip with a plain message. Saving again keeps every one of these rules as read, including fields falcon doesn’t know. The chip’s menu has AI Search Preferences… and Remove This Search; Clear also removes it |
| API | /api/v2/aiSearch/*: see the HTTP API chapter |
The worker owns the model and drops it after two minutes with nothing to do, since it holds about 600 MB. Searches go ahead of indexing, and run off the UI thread; the answer is picked up in ai_tick. The index is searched by comparing with every vector, so a library of 100,000 items holds about 200 MB of vectors in memory.
Tests use a stand-in embedder (crates/falcon-clip/tests/service.rs), so cargo test needs neither the network nor the weights. crates/falcon-clip/tests/real_model.rs holds the tests against the real model, #[ignore]d, and says how to run them.
Menu bar
On macOS falcon has a native menu bar, built with AppKit in crates/falcon-app/src/menu.rs. It follows the order of Eagle’s menus (app.bundle.js, seen) and holds only what falcon can do:
| Menu | Items |
|---|---|
| Falcon | About Falcon (falcon’s own, in place of winit’s), Services, Hide, Hide Others, Show All and Quit, plus Preferences…, Color Space and Lock Falcon after About |
| File | New… (⌘N), New Folder, New Subfolder, New Smart Folder…, New Text File, Import Folders…, Import Links…, Import Files…, Import ▸ (Eagle Package…, Inboard Backup…), Export ▸ (To Computer…, As .eaglepack…, In Another Format…, To CSV…), Close Window |
| Library | New Library…, Open Library…, Merge Library…, Clear Library History…, Empty Cache and Reload… |
| Edit | Undo, Redo, Cut, Copy, Paste, Select All, Rename, Duplicate, Copy Details ▸ (File Paths, Folder Paths, Names, Eagle Links, Source URLs, Thumbnail, Base64), Refresh Thumbnail, Custom Thumbnail ▸ (From a File…, From the Clipboard, Reset), Move Folder ▸ (Up, Down, To the Top, To the Bottom), Edit Image ▸ (Flip Horizontally, Rotate Left, Crop…, Combine Images…), Folder Settings…, Folder Password ▸ (Create…, Change…, Remove…, Lock Now; for the open folder: Create only without a password, the others only with one, Lock Now only while it is unlocked), Move Folder…, Remove from Folder, Move to Trash |
| Find | Search, Search All, Filter Sidebar, Go to Folder…, Add to Folders… (⇧⌘J), then Filter Bar, Clear Filter and an Open Filter ▸ submenu (Color, Tags, Folders, Shape, Rating, Type, Date, Date Modified, Dimensions, Size, Duration, Comment, Notes, URL, Font, BPM, Camera, then More Filters, which opens the + menu), then Find Duplicates |
| Organize | Add Tags…, Add to Last Used Folder, No Rating, 1 Star … 5 Stars, Copy Tags, Paste Tags, Clear Tags |
| View | Always on Top; All Items, Uncategorized, Untagged, Recently Used, Random, All Tags, Trash; Waterfall, Justified, Grid, List; Show Name, Show Item Info, Show Comment Count, Show Subfolder Content; Scroll to Top, Scroll to Bottom, Page Up, Page Down, First Item, Last Item; Zoom In, Zoom Out, Actual Size, Zoom to Fit, Grayscale, Navigator; Viewer, Open in New Window, Open With Default App, Open With Another App… (asks for an app), Reveal in Finder, Open Link in Browser; Sidebar, Inspector, Show or Hide Both Panels, Presentation Mode; Back, Forward, Parent Folder, Shuffle Again, Expand or Collapse All Folders |
| Actions | Falcon’s actions for the open library, each with its shortcut; shown only while there are any, as in Eagle, where Actions is a plugin (see Actions) |
| Window | Minimize, Zoom, Full Screen, Collect Window, Notification Center, Next Library, Previous Library, Close Window, then macOS’s window list |
| Help | Falcon Documentation (the book in docs/, built or not, when falcon runs from its source tree; disabled otherwise), Show Tips, Error Logs, Export Debug Report… |
- Each item shows its chord from the shortcut table (Undo, Redo and Cut show egui’s fixed ⌘Z, ⇧⌘Z and ⌘X). The menus are rebuilt when the bindings, the language or the library’s actions change, so a chord changed under Preferences → Shortcuts shows at once, an unbound command shows none, and the menu titles and items follow Preferences ▸ General ▸ Language (see Localisation).
- An item is enabled when its command can act, by the same rules the keys follow (
menu::enabled): Rename needs a selection and no viewer, Add to Last Used Folder a folder used before, Custom Thumbnail one item (Reset only when it has a custom thumbnail; it resets the first item acted on, as Eagle does), Remove from Folder a selection inside a folder, the Move Folder ▸ entries an open folder or smart folder and not several picked ones (as in Eagle), Move Folder… picked folders or an open folder (it moves the picked ones if any, as Eagle’s does), Parent Folder a folder with a parent, Shuffle Again (R) the Random page or a folder or smart folder sorted at random (as Eagle’srefreshRandom), Next and Previous Library a second open library, the Edit Image entries an image of a kind falcon can flip, rotate or crop (Combine Images two or more images it can decode, outside the Trash and the viewer). Lock Falcon needs an app password. Color Space (sRGB, Display P3 or Unmanaged, Eagle’scolorSpacesetting and its default) carries a checkmark on the choice. Everything is disabled while Preferences is open or falcon is locked. The open page, the layout, the caption switches, the sidebar, the inspector, the filter bar, the viewer, presentation mode, Always on Top, Grayscale, Navigator and full screen carry checkmarks. The app hands the menu a fresh state only when it changes; AppKit reads it when a menu opens.
Color Space. The App menu’s Color Space submenu, after Preferences as in Eagle (app.bundle.js, seen), offers sRGB, Display P3 and Unmanaged. Unmanaged is the default, as in Eagle, and the choice is kept in the preferences (color_space). colorspace.rs applies it by setting the colorspace of the window’s CAMetalLayer (the layer of wgpu’s Metal surface, a sublayer of the window’s content view’s own layer) to kCGColorSpaceSRGB, kCGColorSpaceDisplayP3, or nil for Unmanaged, at start and on every change. wgpu sets the layer’s colour space to nil each time it configures the surface, which it does on a resize, so falcon compares the layer with the choice every frame and sets it again when they differ. The submenu is disabled while Preferences is open or falcon is locked, as in Eagle.
This is separate from a picture’s own colour profile. falcon_library::color turns a picture with a profile into sRGB when the viewer shows it and when falcon draws its thumbnail, so an Adobe RGB or Display P3 file no longer looks dull. Colours outside sRGB are clipped. Rotating, flipping, cropping and exporting read the file’s own pixels through thumbnail::decode, which leaves them alone. Thumbnails made earlier keep their colours until refreshed.
- Keys stay with egui. Every falcon menu has a delegate that tells AppKit it has no key equivalents, so a key press never fires an item: egui gets it as before, text fields keep ⌘C, ⌘V, ⌘A, ⌘Z, ⌘← and the rest, and bare keys such as R or the digits still type. Should AppKit fire an item from a key anyway (the application menu has no such delegate, so ⌘, comes this way), falcon turns it back into the key egui would have had. Clicking an item runs the same
App::run_commandas its key. Undo, Redo, Cut, Copy, Paste and Select All act on a focused text field when there is one. - Behind a dialog nothing else runs. While any modal is up (
App::any_modal_open: the confirmations and prompts, the import prompts, the folder, tag, export and tool dialogs, Add to Folders, the tag picker, the Actions panel and its editor, Preferences and About)App::keyboardand the viewers’ key handlers return early andmenu::enabledturns every item off, as Eagle’s modals do. What stays is the dialog’s own Return, Esc and typing, the window items (Minimize, Zoom, Full Screen, Always on Top), and Cut, Copy, Paste, Select All, Undo and Redo while a text field has focus. ⌘Q is the application menu’s own and always works. - File ▸ New… (⌘N,
file.create.new) and the + button in the sidebar header (tooltip “Add”) open Eagle’s New menu: a searchable menu (type to filter, as the context menus) under the button, or under the toolbar’s left end while the sidebar is hidden. It holds New Folder, New Subfolder (in a folder), New Smart Folder…, Import Folders…, Import Links…, Import Files…, Import Eagle Package…, an Auto-Import submenu (Settings… opens Preferences on the Auto-Import pane; while auto-import is on and a folder is set, a disabled row shows the watched folder), a Find Duplicates submenu (All Items; Current List, disabled when the view has no items; Selected, disabled unless more than one item is selected; each opens the Duplicates page set to that range), Sketch, Photoshop and Illustrator documents, Text Document, Word, PowerPoint, Excel and XMind documents (blank files falcon generates), and an Other Documents submenu listing the blank documents it can’t write (XD, Keynote, Pages, Numbers, MindNode) disabled. Import Links… sits after Import Folders… (see Importing links). The Pinterest and ArtStation entries are left out (ui/menus/new.rs). - Left out because falcon can’t do them yet: the link and site importers and folder exports from the menu, Duplicate, custom thumbnails from the menu, Open Link, the caption toggles, Lock and Color Space, and the online Help items.
About
Falcon ▸ About Falcon opens a small window centred over the app: the app icon, the name, “Desktop asset manager for Eagle libraries”, the version (from the crate version) and a line saying the code is licensed under MIT or Apache-2.0. OK, Esc or a click outside closes it. While it is open the menu commands wait, as they do behind Preferences. On the first menu build falcon removes winit’s stock About item and inserts its own at the top of the application menu (menu::ABOUT); it is always enabled.
Tips
Help ▸ Show Tips opens a card over the window, on a dimmed background, with six short tips, one at a time: adding files, the viewer, search, filters, tags and smart folders, and jumping to a folder. It is a falcon addition; Eagle has no such overlay (its tutorial is a video link on its website). The card shows the tip, “1 of 6”, Back and Next (Done on the last tip) and a close button. Esc, the close button and Done all close it. Nothing has to be read to carry on. A click outside does not close it. While it is open the menu commands and keys wait, as behind About.
A tip that names a key shows the key as it is bound now (Preferences ▸ Shortcuts), and leaves the key out of the sentence when the command has none. Show Tips has no default key and works with no library open, and always starts at the first tip.
The tips also open by themselves, once: after the welcome flow is finished, a library is open and no dialog is up. Closing them by any route is remembered (tips_seen in the saved state), so they don’t come back. State saved by an earlier version has no tips_seen, so a user who has already used falcon sees the tips once on the first launch after the update. The rules are in tips.rs (should_show_on_launch), the card in ui/dialogs/tips.rs. Nothing is fetched or sent.
Error logs
Falcon writes errors, such as a library that fails to open or a crash, to ~/Library/Logs/Falcon/falcon.log (%LOCALAPPDATA%\Falcon\Logs on Windows), one line each with the local time and ERROR. When the file passes 1 MiB it is renamed falcon.1.log (replacing an older one) and a new log starts. Help ▸ Error Logs opens the file with the default app, creating it first if it is missing. The log is for diagnosis only; nothing is sent anywhere.
Debug report
Help ▸ Export Debug Report… asks where to save (the Desktop and Falcon Debug Report.zip by default) and writes a zip. It holds debug-report.txt, the open library’s metadata.json, mtime.json, tags.json and saved-filters.json when they exist, and the error log as falcon.log. The text has sections for the app (version), the system (macOS version and build, model, CPU, logical CPUs and memory, asked of sw_vers and sysctl, with “unknown” for anything that fails), the display (window size in points and pixels per point) and the library (path, item, folder and tag counts, each JSON file’s size and whether it is writable, then the other open libraries and the recent ones). Without an open library that section says so. The zip is made with ditto from a temporary folder that is deleted afterwards, then shown in Finder. Nothing is tested over the network or on the disk, and nothing is sent anywhere; the report may contain library paths and tag names, so look through it before sharing. A failure is logged and shown as a message.
Dropping files from Finder
While files are dragged over the window from Finder or another app, falcon shows where they would land, as Eagle’s drop areas do (index.html box-container-droparea and sidebar-droparea, seen): over the grid a framed hint says the files will be imported (into the open folder, when one is open); over the sidebar, that folders dropped there are imported with their subfolders; over a folder row, the row is outlined. The target is worked out the same way as for the drop itself (drop_pos::target), from the system’s mouse position, since macOS sends no mouse moves during the drag (crates/falcon-app/src/ui/drop_hint.rs).
Context menu search
As in Eagle (app.bundle.js, showSearch: true, seen), the item, folder, smart folder, tag, tag group, palette swatch, filter bar + and Saved menus, and the New… menu, open with a search box on top that already has focus, so you can type straight away. Typing replaces the menu with the entries whose label holds every typed word (any case), earliest match first; ↑ and ↓ move the highlight, Enter or a click runs the entry, Esc closes the menu. Disabled entries and submenu buttons are left out, as in Eagle. Entries inside submenus are searched too and listed under the submenu’s name, after the top-level matches (before them when no top-level label starts with what was typed and a submenu entry’s own label matches, as Eagle orders them); typing a submenu’s name lists its entries. Clicking into the box doesn’t close the menu. In the Saved menu the per-filter ⋯ submenus (Rename, Move, Remove) are left out of the results, so the search finds saved filters by name. The nested menus are searched as well: Change Icon in the folder and smart folder menus (while filtering, the icon grid becomes one entry per icon, labelled with its glyph and Eagle’s icon name, so “film” finds the film icons), Add to Group and Move to Group in the tag menu, and the batch rename dialogs’ Insert button, whose menu has its own search box and finds the Number and Date entries (a date form is listed under its source, Date ▸ Today, Modified or Created, so “modified seconds” finds one entry; ui/menus/insert.rs). Eagle’s inspector Help button is not built: its menu is mostly online links (Help Center, Tips, Roadmap, Privacy, API documentation, Twitter, Check for Updates) beside About, Preferences and Shortcuts, which falcon has in the app menu.
The Help menu in the menu bar is registered with macOS as the application’s help menu (NSApplication.setHelpMenu, as Eagle’s role: 'help' does), so macOS adds its own search field at the top of it and finds every menu item of the app by title. That field is the system’s, not falcon’s. Eagle also has a Help button at the foot of its inspector whose menu is searchable (About, Check for Update, Preferences, Help Center, Tips, Shortcuts, Privacy, API, Twitter); falcon has no such button.
crates/falcon-app/src/ui/menus/search.rs wraps a menu’s body without changing it: while you type, the body still runs each frame out of sight, the app collects its enabled buttons and their labels, and running an entry hands that button an accessibility click, so the menu code acts exactly as if it had been clicked. egui only builds a submenu while it is open, so the menus open theirs with menu_search::submenu(ui, name, |ui| …) instead of ui.menu_button: while the search filters, it runs the submenu’s contents inline in the hidden body and reports the area they took, so their buttons are collected under the submenu’s name; otherwise it is ui.menu_button. The item menu (Open File Location, Open With, Copy, Thumbnail Background, Export) and the folder and smart folder menus (Password, Sort Items By, Export) use it; Change Icon’s icon grid is left out of the search.
Keys
These are the defaults. All but the arrows, Esc, Tab, Z and the viewer’s C, 0, 1, +, − and ` can be changed under Preferences → Shortcuts (see Shortcut settings); the video and audio player keys are its Player group. Bare keys (the digits, R, T and /) don’t answer their ⌥ chord. Apart from the Player keys, every rebindable command is also in the menu bar (see Menu bar).
| Key | Action |
|---|---|
| Space (or ↩) | Open or close the viewer. With audio or video in the viewer, or a GIF with the frame player showing, Space plays or pauses instead (Esc or ↩ closes) |
| ← → ↑ ↓ | Move the selection (with ⇧, extend it; see Selection), or go to the next item in the viewer (with a PDF, ↑ ↓ scroll the document). With ⌘ or ⌥, ← and → go back and forward instead (see below) |
| W A S D | The same as ← → ↑ ↓ (Eagle’s bare letter arrows), unless a letter is bound to a command under Preferences → Shortcuts |
| ← → ↑ ↓ in the sidebar | After a click in the sidebar the arrows act there, as in Eagle’s focus zones: ↑ and ↓ open the row above or below (the fixed entries, Quick Access, smart folders and folders, in the order shown, scrolling it into view), ← collapses and → expands the open folder or smart folder, or every picked folder. A click anywhere else hands the arrows back to the grid. On the All Tags page there is a third zone, the page’s tag sidebar: → in the main sidebar or a click in the tag sidebar moves there, ← moves back, and ↑ and ↓ open the list above or below (All, Uncategorized, Starred, then the groups in order), as Eagle’s “tags” focus does |
| ⇧Space | Scroll up a page (outside the viewer) |
| ⌘A | Select all |
| ⌘⌫ or ⌦ | Move to the Trash, or delete permanently when in the Trash; the next item is then selected. On the All Tags page, with a group open, it deletes the group when the tag sidebar has the arrow keys (asking first if it holds tags), or takes the picked tags out of the group otherwise, as Eagle does |
| ⌘R (or F2) | Rename the selected item in the inspector, or open the Rename dialog for several |
| ⌘D | Duplicate the selected item (or the viewer’s) |
| ⇧⌘J and F | Add to Folders… for the selection or the viewer’s item. ⇧⌘J (find.add.to) opens the Save / Cancel dialog; the bare F (organize.folder.panel), which only acts when no text field has focus, and Eagle’s own organize.folder.add id (unbound) open the tabbed panel. Eagle’s P (Plugins) has no counterpart: falcon has no plugins |
| G | Open the Actions panel (organize.actions.panel), as Eagle’s G does; also the toolbar’s Actions button (see Actions) |
| ⇧D | Add the selection to the last used folder |
| ⌥⌘T and ⇧⌥⌘T | Custom thumbnail for the item from a file, or from the clipboard |
| ⌘F | Focus search |
| ⌥⌘F | Search All: open the All page and focus search |
| ⌥⇧F | Filter Sidebar: show the sidebar and focus its folder filter box |
| ⇧⌥⌘F | Clear Filter: switch every filter rule off and release the Lock |
| ⌥F, ⌥T, ⌥C, ⌥S, ⌥R, ⌥D and ⌥E | Show the filter bar and open the Folders, Tags, Color, Shape, Rating, Date and Type filter (Eagle’s find.filter.* keys). The Size, Dimensions, Duration, Comment, Notes, URL, Font, BPM and Camera filters have rows under Preferences → Shortcuts and no default key. The bare T and R keys keep their tag panel and reshuffle jobs |
| ⌘J and J | Go to Folder: a switcher for folders, tags, smart folders and items (see Search and filters), also opened by the ⇄ button in the sidebar header. The bare J only acts when no text field has focus |
| ⌫, ⌘← and ⌥← | Back. In the viewer, ⌫ closes the viewer (as in Eagle) and the two arrow chords do nothing; with a video playing, ⌘← still skips in the video. None of the three acts while a text field has focus |
| ⌘→ and ⌥→ | Forward. In the viewer they do nothing; with a video playing, ⌘→ still skips in the video. Not while a text field has focus |
| ⌘] and ⌘[, ⇧⌘] and ⇧⌘[ | Move the open folder (or open smart folder) up or down one place among its siblings; with ⇧, to the top or the bottom (Eagle’s Edit ▸ Move Folder). Each move is one undo step |
| ⇧⌘R | Folder settings (name and auto-tags) for the open folder, unless it is locked (Eagle’s Set Current Folder, edit.folder.setting) |
| ⌘= and ⌘− | Zoom the grid’s cards, or zoom the image in the viewer |
| ⌘N | The New… menu (file.create.new), also the + button in the sidebar header; see Menu bar |
| ⌘⇧N | New folder, right after the open folder among its siblings, or at the top level when no folder is open (as Eagle) |
| ⌥N | New subfolder of the open folder, first among its siblings (as Eagle) |
| ⇧⌥⌘N | New smart folder |
| ⌥⇧N | New text file: an empty “Untitled.txt” item in the open folder (with its auto-tags), opened in the text viewer with the cursor in the text (Eagle’s fixed key; rebindable in falcon) |
| ⇧⌘⌫ | Remove the selection from the open folder, then select the next item; as in Eagle there is no prompt, and an item left in no folder becomes Uncategorized |
| ⌥↑ | Open the parent of the open folder or smart folder |
| ⌥⇧C | Make the first selected item the open folder’s cover, with a note naming the folder (Eagle’s fixed key; rebindable in falcon as edit.folder.cover, also Edit ▸ Set as Folder Cover and the card menu). Does nothing outside a folder or with nothing selected, or while a text field has focus |
| / and * | Expand every folder if the reference folder (the open folder’s parent, or the first folder) is collapsed, else collapse them all; in a smart folder, the smart folders |
| ⌘C and ⌘V | Copy the selected files, or paste files or images |
| ⌘E | Export the selected items’ originals to a folder (see Export and copy-out) |
| ⇧⌘E | Export the selected items as an .eaglepack |
| ⇧E | Export the selected images in a chosen format and size |
| ⌥⌘C | Copy the selected items’ file paths, one per line |
| ⌥⇧⌘C | Copy the selected items’ <id>.info folder paths, one per line |
| ⌘Z and ⇧⌘Z | Undo and redo library edits, up to 100 steps. Each frame’s edits are one step, named after what was done (“Undid: Move 3 to Trash”). Deleting permanently or emptying the Trash can’t be undone and clears the history. Text fields keep their own undo while focused |
| ⌘⇧F | Show or hide the filter bar |
| ⌘⌥1 and ⌘⌥2 | Show or hide the sidebar and the inspector, in the grid and in the viewer |
| ⌘⌥3 and Tab | Show or hide both panels together: both show when the sidebar is hidden, otherwise both hide (Eagle’s view.toggle.all; Tab is fixed and does nothing while a text field has focus) |
| ⌥1 to ⌥4 | Waterfall, Justified, Grid and List layouts, as in Eagle; the layout is remembered for the open folder or smart folder, else for the library’s other pages |
| ⌥⌘4, ⌥⌘5 and ⌥⌘6 | Show or hide the names, the item info and the comment counts on cards (in the List layout the count is a disc at the right of the row) (Eagle’s view.toggle.listname, listmetas and listannotation) |
| ⌥⌘7 | Show Subfolder Content: a folder also lists the items of its subfolders (Eagle’s view.toggle.subfolder) |
| Esc | Leave presentation mode, else close the viewer, or clear the selection |
| ⌘O | Open the viewer’s item, or the first selected, in a window of its own (Eagle’s view.file.opennewwindow) |
| ⌥⌘8 | Show or hide the image viewer’s navigator (Eagle’s view.toggle.navigator) |
| ⌥⌘G | Grayscale preview of the viewer’s pictures, or back to colour (Eagle’s view.grayscale) |
| ⌥⌘G | Grayscale preview of the viewer’s pictures, or back to colour (Eagle’s view.grayscale) |
| ⇧T | Keep the main window above other windows, or stop (Eagle’s view.alwaysOnTop) |
| F5 | Enter or leave presentation mode: the viewer full screen without the panels or its bar (Eagle’s view.toggle.slideshow) |
| ⌘0 and ⌘9 | In the viewer, actual size and zoom to fit (Eagle’s view.zoom.actual and view.zoom.fit) |
| 0, 1, + and − | In the viewer, zoom to fit, actual size, zoom in and out (only without ⌘, ⌃ or ⌥) |
| ` | In the image viewer, fit every image to the window edge, or stop (Eagle’s fixed key) |
| Hold Z | Show the hover preview of the card under the pointer |
| ⌘⇧K | Show or hide the collect window |
| ⌘L | Open the library menu (Eagle’s library.switch) |
| ⌃⇧Tab and ⌃Tab | Previous and next open library (falcon’s own) |
| ⌘, | Preferences |
| ⇧⌘L | Lock the open folder again (Edit ▸ Folder Password ▸ Lock Now) |
| ⌥⌘L | Lock falcon (with a password set in Preferences ▸ Password) |
| ⌘1 to ⌘7 | All, Uncategorized, Untagged, Recently Used, Random, All Tags and Trash (Eagle’s View menu order). From the viewer they close it first |
| Home, End, Page Up and Page Down | Scroll the grid to the top or bottom, or by a page (the view less 72 px, as in Eagle) |
| ⌘↑ and ⌘↓ | Select the first or last item and scroll to it (with video in the viewer they are still the volume keys) |
| 0 to 5 | Set the rating of the selection (0 removes it), with a toast such as “Rated 3 stars”. In every viewer the digits rate the open item, as in Eagle (the image viewer’s zoom keys are ⌘0 and ⌘9); in the text viewer only while neither the text nor the title has focus, as in Eagle’s text editor |
| ⌥⌘R | Refresh the thumbnails of the selection (or the viewer’s item) |
| R | On the Random page, or in a folder or smart folder sorted at random, shuffle again (no history step); the toolbar shows a Refresh button for it there, left of the filter button, as Eagle’s does |
| ⇧⌘C and ⇧⌘V | Copy the selection’s tags (the tags all of them share) and paste them onto the selection (see Copying tags). Clear Tags has no key by default, as in Eagle |
| ⌘T and T | Open the tag picker for the selection (or the viewer’s item) at the pointer: Eagle’s Organize ▸ Add Tags (organize.tag.add) and its bare T. T only acts when no text field has focus |
| ⌘W | Close the viewer if it is open, otherwise the window. As with the red button, falcon keeps running in the Dock, as Eagle does on macOS: it hides (finishing any pending saves first), and clicking its Dock icon or opening it again brings the window back. ⌘Q quits (crates/falcon-app/src/dock.rs) |
| ⌘M and ⌃⌘F | Minimise the window, and enter or leave full screen |
| ⇧↩ | Open the selected items (or the viewer’s item) in their default apps (Eagle’s view.file.opendefault) |
| ⌘↩ | Show the selected items (or the viewer’s item) in Finder (Eagle’s view.file.openfinder) |
| ⌥⌘S and ⌥⌘D | Second keys for Show in Finder and Duplicate (Eagle’s shortcut editor lists view.reveal.darwin and view.duplicate.darwin with these defaults; nothing in Eagle’s code reads them). They run the same code as ⌘↩ and ⌘D, in the grid and in the viewer, and have their own rows under Preferences → Shortcuts. macOS reserves ⌥⌘D for hiding the Dock by default, so that chord reaches falcon only when the system shortcut is turned off. The menu bar shows the first key of each action only |
| ⇧⌘O | Open the source links of the selected items (or the viewer’s item), up to 50; file:// links and existing paths open in their app, web addresses in the browser (Eagle’s view.file.openlink) |
| C | In the viewer, turn comment mode on or off (Esc also leaves it) |
| ⇧R | Rotate the viewer’s image left (edit.image.rotate, rebindable; unbound means no key) |
| ⇧F | Flip the viewer’s image horizontally (edit.image.flip, rebindable; unbound means no key) |
| ⇧C | Opens the crop tool on the viewer’s image, or on the one selected item (opening the viewer); while cropping, it saves (then confirm), as Enter does. Rebindable as edit.image.crop |
| Enter and Esc | While cropping, save (then confirm) or cancel |
| Arrows, W, A, S, D | While cropping, move the box 1 px in the image, stopping at its edges; with ⇧, 10 px, as Eagle’s Mousetrap keys do (the letters only with no modifier and when no command has them). Page Up and Page Down also move it 10 px, which is falcon’s: in Eagle they pan the view |
| ⌘ arrows, ⌘⇧ arrows | While cropping, resize the box 1 px or 10 px by its right and bottom edges, stopping at the image and at Eagle’s 24 px (Eagle lets one press take it below 24 and the next snap back; falcon stops there) |
| ⌘S | While cropping, save (then confirm), as Enter does. The size readout stays up a second after any of these keys, as in Eagle |
| ⌘← and ⌘→ | With video or audio in the viewer, skip back or forward 2% of its length, at most 10 s |
| ⌘↑ and ⌘↓ | With video or audio in the viewer, volume up or down 5% |
[ and ] (with ⇧, ten) | With video in the viewer, previous or next frame; with audio, back or forward 1/24 s |
| ⇧, and ⇧. | With video or audio in the viewer, slower or faster |
| N | With video in the viewer, add a comment at the current time |
| ⇧R and ⇧F (the keys bound to Rotate Left and Flip Horizontal) | With video in the viewer, turn the picture a quarter anticlockwise or mirror it (view only; on images they rotate and flip the file) |
| ⇧C and ⇧S | With video in the viewer, copy the current frame, or save it to the library as a new item (“Set current frame as thumbnail” has no default key) |
| n/j and p/k, Home and End | With a PDF in the viewer, next and previous page, first and last page (⌘↑ and ⌘↓ too); ↑ ↓ and Page Up/Down scroll |
| ⌘+, ⌘− and ⌘0 | With a PDF in the viewer, zoom in, out and back to Automatic |
| ⌘P | With a PDF in the viewer, print it |
| ⌘F, ⌘G and ⇧⌘G | With a PDF in the viewer, find in the document and step through matches (↩ and ⇧↩ in the find field) |
| ⌘C | With text selected in a PDF, copy it |
| ⌘= and ⌘−, ⌘0 and ⌘9 | With a text item in the viewer, larger or smaller text, and back to the smallest size (also ⌘, ⌃ or ⌥ with the wheel, or a pinch). Not while typing, as in Eagle |
| ⌘S | While typing in the text viewer, save now |
| F5 | In the text viewer, read the file again (not while typing) |
| ⌘-click | On a link in the text viewer, open it in the browser |
| R and ⇧R, S and H | With a PDF in the viewer, turn the pages clockwise or anticlockwise (view only), pick the text selection or hand tool |
Saving
The app opens libraries in batched write mode (see Write batching): edits made during a frame, including those from the HTTP API and background imports since the last frame, are written together at the end of the frame. Pending writes are also flushed when a library is closed and, for every open library, when the app saves its state (every 30 seconds and on quit). A failed write shows “Could not save”. A metadata.json write is refused when the file on disk was saved by a newer Eagle or has a later modificationTime than falcon last saw, which means another app or a sync service changed it (see the write guard). The app then shows a Library changed elsewhere alert saying falcon didn’t save over it, reloads the library from disk and asks the user to redo the last change if it is missing. Other pending writes go out first. If the library can no longer be opened, because it is now from a newer Eagle or its folder is gone, it closes with a Library closed alert.
Metadata backups follow Eagle’s timing (see Backups). When a flush writes mtime.json or metadata.json, a backup is scheduled 1 second later, and each further write restarts the wait. Every open library is also backed up every 30 minutes. Both run backup::backup_library on a background thread, so the copy never blocks the window, and a library never has two running at once. The copy goes into the library’s backup/ folder, is skipped when the newest backup has the same size, and at most 100 are kept. Each new backup also replaces the library’s copy in eagle-backup/ in falcon’s app data folder (~/Library/Application Support/Falcon/eagle-backup/<fnv1a-32 of the library path>.json), and a backup never recreates a library whose folder has gone. When a library closes or the app saves its state with a backup still waiting, it is taken right away on the main thread so a quit doesn’t lose it. The timing lives in crates/falcon-app/src/backups.rs, whose Schedule takes the time as an argument and has unit tests; nothing in the tests waits on a timer.
Metadata backups have no setting: falcon backs up after a change the way Eagle does (Eagle has no preference for it either). An earlier “Back up metadata on change” checkbox that did nothing was removed; old saved state that has it still loads.
Status messages
Short notes appear in a small rounded box at the bottom centre of the window (in the middle of the picture while the viewer is open). Only one shows at a time, and a new note replaces the old one. A note stays up while the pointer is over it.
- Quick confirmations, such as anything copied, a rating or “Refreshed”, go after about a second.
- Other notes stay for three seconds, or longer when the text is long (one second less over the viewer).
- A failure (“Could not save”, “Export failed” and the like) has a red mark before the text and stays for at least six seconds.
- Notes for actions that can be undone have an Undo button at the right and stay for at least five seconds. Clicking it undoes the last step, as ⌘Z does, and closes the note. It is offered after moving items to the Trash, removing items from a folder, adding or dropping items on a folder, clearing or pasting tags, and renaming, removing or merging tags. Restoring from the Trash and permanent deletes have no Undo button.
- Notes about adding or moving items to a folder end with the folder’s name as a link. Clicking it opens that folder.
The import error box and the progress bars are separate and unchanged.
Notification Center
A falcon addition; Eagle has no such panel. Window ▸ Notification Center (no default key; the shortcut window.notifications can be bound in Preferences) shows or hides a window listing the notes above, the Collect window’s status lines and the dialog notices (a library that is too new, a merge that finished, and the like), newest first. It does not block the app and opens with or without a library. Esc closes it unless a dialog, menu or text field has the key. Each row has its time (the clock time for today, else the date and time), a red mark for a failure, a count such as ×3 when the same text was shown several times in a row (they are one row), and the text, wrapped and selectable. The Clear button empties the list. Only the text is kept: the Undo button and folder link of a note are not replayed. A dialog notice is listed as its title and body. The most recent 200 are kept, across restarts and for every library together (they are saved with the rest of the app state). The Collect window’s own status line is not listed. The list is notice_history.rs; every flash* call goes through set_toast, which records the text once, however long the note stays up.
Background task status
Eagle shows a small spinner beside the library name while its background queue (colour analysis of new items) has work; its tooltip reads Processing or Paused with the number left, and a click pauses or resumes the queue. falcon analyses colours as part of each import, so its queue is the import thread: while files are left, a turning ring sits beside the library name (ui/sidebar/task_status.rs, rules in tasks.rs) with the same tooltip. Clicking it pauses the queue before its next file (the file being imported finishes), shows a resume glyph and turns the strip under the toolbar to “Paused at N of M”; clicking again resumes. Thumbnail refreshes run behind their own progress window with Cancel and are not part of this queue.
Several libraries
Falcon can keep several libraries open in one window and switch between them; Eagle can’t. Each open library is a workspace with all of its own state: where you are (scope, back and forward history, search, filters, sort and layout), the selection, the viewer, its undo and redo stacks, open dialogs and its image caches. Switching keeps all of it. Global state stays shared: Preferences, shortcuts, the recent list, zoom, panel visibility, the collect window and the HTTP API server.
- Switching: click the library name at the top of the sidebar or press ⌘L. The menu lists the open libraries first (name over its folder, dimmed, the shown one highlighted, × to close), then recent libraries that aren’t open, then Open Library…, New Library…, Show in Finder, the collect window and Preferences. ⌃⇧Tab and ⌃Tab step through the open libraries.
- Opening a library that is already open shows it instead of loading it twice (paths are compared after resolving symlinks). A new workspace starts with the shown library’s sort and layout and is added at the end of the list.
- The loading screen appears while a library is read on a background thread: one chosen in the switcher, with Open Library… or dropped on the window, the libraries reopened at launch, one just created, and the missing-library screen’s Try Again and Select Another Path…, so a large library doesn’t freeze the window. It shows “Opening
” with a progress bar and a line that reads “Reading the library…” while the item folders are being listed, then “Reading items: 50 of 200”, then “Almost there…”. It fills the window when no library is open and is a dialog over the shown library otherwise. Only one library loads at a time; others wait their turn in a queue ( app/open_queue.rs), and the install-folder check and backup restore run on that thread. When it finishes the library appears in a workspace of its own. At launch, the library shown before quitting (or the one given on the command line) is shown once all are read, and the command line’s items, search, colour, tags page, imports and links, as well as links and files opened with falcon meanwhile, wait until then. Try Again on a folder that is still missing says so at once without a load; a path picked with Select Another Path… replaces the old one in the recent list once it has opened. - Storage warnings come from a check run once on a background thread when a library opens (
crates/falcon-app/src/storage.rs), after Eagle’s warnings. It tries to openmetadata.jsonfor appending, creates and removes a<ms>.tmpfolder in the library, times that round trip, and asks macOS for the volume type. A read-onlymetadata.jsonor a folder where nothing can be created gives a “may not save changes” alert, with the reason and a hint to check permissions or move the library. A round trip slower than 1.5 s (falcon’s own threshold, since Eagle’s isn’t in its readable source) or an NTFS volume gives a toast instead, saying the disk is slow or that NTFS drivers on macOS are slow and less reliable and that APFS or exFAT works better. - Outside changes are picked up while a library is open. Each workspace polls the library’s
metadata.jsonandmtime.jsonevery 4 seconds on its own thread (crates/falcon-app/src/watch.rs; see the library format). A newermetadata.jsonreplaces the folder tree, and the undo and redo history and any open folder edit are cleared since they no longer fit it. Items added, edited or deleted elsewhere appear, change or go, and the thumbnail cache forgets the ones that changed, and an item open in the editor that changed is closed. Falcon’s own saves cause no change. This covers libraries that aren’t shown as well as the one on screen. - A library whose folder vanishes (moved, renamed, deleted or its drive ejected) closes without the flush and backup a normal close does, because those writes would fail or recreate files in the old place. Pending writes are discarded, and the missing-library screen opens for it.
- The missing-library screen is titled “Library not found” and names the path: there is no library folder there, and it may have been moved, renamed or deleted, or its drive isn’t connected. It appears when opening a library whose folder doesn’t exist (a recent entry marked missing, or a path passed in), when a library’s folder vanishes while it is open, and at launch when the library that was shown when falcon quit is gone (a hidden library that is gone is only skipped, as before). It fills the window when no library is open and is a dialog with an extra Close button otherwise. Try Again opens the same path again and flashes “The library is still missing” if it is still gone. Select Another Path… opens a folder picker and, when a library opens from it, removes the old path from the recent list. “Can’t find it? Create a new library” opens the Create a Library dialog.
- Merge Another Library… asks for a folder, then shows “Merge
into ?” with what would come in and Merge and Cancel buttons. The open library itself, a folder with no metadata.jsonand a library from a newer Eagle are refused with a “Can’t merge that library” alert. Merge copies the missing item folders on a background thread behind a “Merging” dialog that can be cancelled, then merges the folder tree. A Merge finished alert gives the numbers of items, folders and smart folders or tag groups added. If the items were copied but the folders couldn’t be saved, a Merge not finished alert says so. The rules are in Merging libraries. - Empty Cache and Reload… asks “Empty cache and reload?” (Empty Cache and Reload or Cancel). On confirming, falcon writes what is pending, reads the shown library from disk again (restoring a backup if
metadata.jsonis damaged), rebuilds itsmtime.jsonfrom the items, drops every decoded thumbnail and preview, and flashes “Library reloaded”. A large library takes a while. If the save before it fails, nothing is reloaded. - Import Eagle Package… asks for an
.eaglepackand reads it without extracting it (see Export). A dialog says how many items the package holds and how much disk space they take in this library, with Import and Cancel. When some look like items already in the library, a second dialog, titled with their count (“3 items already in the library”), asks whether to import them anyway: Import brings everything in, Leave Them Out imports the rest (Eagle’s Don’t Import), and closing the dialog with Esc imports everything, as dismissing Eagle’s does. The import then runs behind a progress modal with a count of items and a Cancel button; Cancel stops before the next item and the items already imported stay. A file that isn’t a readable package gives a “Can’t import the package” alert. A package picked here goes into the open folder if one is open. Dropping an.eaglepackon the window does the same, and one dropped on a folder goes into that folder. Only the first package of several dropped files is taken, and the other files import as usual. - Import Inboard Backup… asks for a backup folder. A folder without
InboardBackup.jsongives a “Not an Inboard backup” alert. Otherwise one top-level folder is created for each Inboard folder and the images import in the background with their names, tags, links, trashed state and dates, and a toast reports “Importingimages and folders from Inboard”, with the number of files missing from the backup. The format is in Inboard backups. - Creating a library (New Library… in the switcher) opens the Create a Library dialog. Enter a name (not empty, none of
@ # $ % ^ & * < > : ' " / \ | ?; at most 127 characters) and press Create or Return; Esc cancels. A system dialog then asks which folder to keep the library in, starting in the folder that holds the shown library. A folder whose path contains “Desktop” or “Downloads” brings a warning that a library there risks being deleted by accident, with Choose Another and Save Anyway. The library is made as<folder>/<name>.libraryand opened. If one already exists there the dialog stays open with a message so another folder or name can be tried. The code is incrates/falcon-app/src/ui/dialogs/library.rs. - The welcome flow replaces the empty window on a first launch, when no library has ever been open or recent. It has three steps with Back and Next and a row of dots: a title with a one-line description, a choice of theme (shown as swatches, applied at once and saved with the preferences), and a name field with a few example names, a Create Library button and an Open Existing Library… link. Create Library checks the name as the Create dialog does and continues with the folder choice and the Desktop and Downloads warning; cancelling the folder choice stays on the step. Once any library is opened or created, the flow is marked finished in the saved state and never returns; a launch that finds saved libraries skips it. If a library failed to open at launch, the plain empty window with the error is shown instead. After the flow, and when the last library is closed, the empty window shows the title, the error if there is one, a Create Library button (opens the Create dialog) beside an Open Library button (picks an existing library folder), and a line saying a library is a folder that holds your files and their tags.
- Closing writes the library’s pending changes, then picks the next library to the right (or the left at the end). Closing the last one leaves the empty window and stops the API.
- Switching while tasks run: switching to another library never stops anything, since a library in the background keeps importing and finishing its operations, but leaving a library that has imports in flight, a long operation behind the progress modal, an export or an AI step in Actions running asks first. “Switch Library While Tasks Run” names the library and says the tasks keep running in the background, with Force Switch and Cancel (Escape or a click outside also cancel); if the tasks finish while it is up, the switch goes ahead. It guards the switcher’s rows, ⌃Tab and ⌃⇧Tab, recent libraries, Open Library… and a dropped library (once the folder is known), New Library… (before its dialog) and the HTTP API’s
library/switch. The API’s library leaves the shown workspace at once, before the question, so running tasks never write to it; Cancel keeps the API on the shown library and a message says so.AI runs count as tasks and keep running after a switch, where Eagle blocks the switch. - Closing while tasks run: closing a library does stop its tasks. When the library has imports in flight, a long operation behind the progress modal, an export or an AI step running, the × asks “Close Library While Tasks Run”: tasks are still running in
, closing stops them, and work already done is kept. Stop Tasks and Close tells the import to skip the files it hasn’t started (they count as done, so the batch ends) and the progress modal’s operation (an export included) to stop before its next item, cancels an AI step’s request, and shows “Stopping tasks…” with no buttons. The library closes, with the usual save and backup, only once every worker has stopped, so nothing writes to it after that save. Cancel leaves everything as it was. The pending close is kept by the library’s folder, since switcher positions shift ( crates/falcon-app/src/ui/dialogs/force_close.rs). - Quitting while tasks run: the same warning guards quitting, since a quit would cut off imports, a progress-modal operation, an export or an AI step in any open library, the one shown or one in the background. Quit asks “Quit While Tasks Run” (tasks are still running, quitting stops them, work already done is kept) with a red Stop Tasks and Quit and a Cancel (Escape or a click outside also cancel). Stop Tasks and Quit stops the tasks of every library as above, shows “Stopping tasks…” until the workers have stopped, and then closes the window for real, so the usual save and backup on quit run. With nothing running, Quit quits at once. It asks from ⌘Q and the app menu’s Quit (falcon replaces winit’s Quit item, which ended the process, with one that asks the app), the menu bar icon’s Quit Falcon (which also brings the window forward so the question is seen), and a window close that would quit (the Dock option off with no menu bar icon). A close that only hides the app never asks. Quitting from the Dock icon’s own menu, or by logging out, is AppKit calling terminate directly, which falcon cannot intercept without replacing winit’s application delegate, so it does not ask (
crates/falcon-app/src/ui/dialogs/quit_confirm.rs). - Window title: the shown library’s name (“Falcon” when none is open). The title bar is hidden, so it shows in the Window menu, Mission Control and the Dock.
- Launch: the open libraries, and which one was shown, are saved with the app state (
librariesandactive) and reopened; a missing one is skipped, except that when the library that was shown is gone the missing-library screen opens for it. A library passed on the command line is opened (or shown, if it was already open) after them. State saved by older versions, which has onlylibrary, opens that one library. - API warnings: two problems with the local API show a “Local API unavailable” alert, which stays until dismissed or until the API is restarted. When no port of the range is free (another program answers on each), it reads that the API couldn’t start, names the ports, says apps and browser extensions won’t reach it and points to the first port in Preferences ▸ Developer. About a second after the server starts, a background thread resolves
localhostand tries a plain connection to each loopback address on the server’s port (crates/falcon-app/src/api_check.rs; no HTTP request is made, and never to port 41595). If none connects, the alert says the API runs on that port but can’t be reached through localhost, so browser extensions can’t reach Falcon, and that a proxy or VPN may be redirecting localhost. A working start still only flashes “API on http://localhost:”. - HTTP API: it serves the shown library and keeps its port when you switch. A
library/switchrequest opens (or shows) that library in its own workspace and gives the one it replaced back its library; see HTTP API.
What Eagle does (Eagle 4.0, from js/directives/library-panel.js and its template, js/background.js, js/api-server.js and js/default-preferences.js; observed unless marked):
- One library per window. The renderer keeps a single library path, and switching (
openLibrary, then theadd-to-history-and-openmessage to the background page) saves the cache, drops the current library and loads the new one in the same window. A dialog first asks to cancel running imports, downloads or saves. No code path opens a second main window for another library (inferred from the absence of one; the main-process window code isn’t in the extracted renderer files). - The library panel opens at the brand icon (
switchLibrary) or withlibrary.switch, ⌘L by default. It has a search field (fuzzy, with pinyin), then one row per entry of thelibraryHistorysetting: the library’s icon (icon.pngin the library, if any), its name without.library, its parent folder, and a check on the open one. Rows can be reordered by dragging, and their menu changes or removes the icon, shows the library in Finder, or removes it from the list. Below the list: Create, Load (open), Reload and Merge. ↑, ↓ and ↩ pick a row; Esc closes the panel. libraryHistoryappends a newly opened library at the end, without duplicates, and keeps the user’s order. A separatepinnedLibrarylist exists.- Item counts are not shown in the panel.
library/switchover the API calls the sameopenLibrary, so the API always serves the window’s library.- Eagle has no next or previous library shortcut;
library.createandlibrary.loadexist without defaults.
How falcon differs: several libraries open at once (above) and its own ⌃Tab and ⌃⇧Tab shortcuts; one window rather than one OS window per library (Eagle doesn’t have several windows either); the recent list is newest first and capped at 10, with no search, pinning or reordering yet. It has icons, Merge Another Library… and Empty Cache and Reload… (Eagle’s Merge and Reload).
Layouts and display options
The toolbar’s layout button opens a panel after Eagle’s (layout-panel.html, seen), in its order: the layout; for the Grid layout, the fill mode (Fit shows the whole image, Fill crops it to fill the square); the Thumbnail option (Speed or Quality, Quality by default as in Eagle’s eagle.list.show.originalImageWhenLarge: a jpg, png, webp, bmp or jfif card drawn larger than its 512 px grid render shows its original instead, decoded on the loader’s workers at up to 1600 px into a cache of 16, about 160 MB at most, after the visible thumbnails; originals still in iCloud aren’t downloaded for it. Eagle swaps once its tile height passes 600 px, which falcon’s 400-point tiles never reach, so falcon asks whether the card would otherwise be enlarged); the sort, with the direction (inside a folder or smart folder, that view’s own sort, as in the folder menu); the switches Show Name, Show Item Info (with what the info line shows: Dimensions, File Size, Tags, Rating, Date Modified or Date Created), Show Extension (at the end of the name), Show Extension Label (the type badge), Show Comment Count, and Show Subfolder Content; Show Sidebar and Show Inspector; and Refresh, which writes anything pending, reads the library again from disk, decodes the thumbnails again and reshuffles random orders. The panel stays open while you flip switches; a click outside closes it. A right-click on empty grid space (in any layout, and on the empty view; not on a card, the subfolder cards or the list header) opens the same panel at the pointer, as in Eagle, and leaves the selection alone. The code is in crates/falcon-app/src/ui/toolbar/layout_panel.rs, the rules in logic/.
| Option | Eagle | falcon |
|---|---|---|
| Layout | Remembered per folder and per smart folder (eagle.list.layout.<id> in local storage); every other page shares one per library; Justified when none is set | Same keys in falcon’s saved state (layouts, by folder or smart folder id or library path). A library with none falls back to the single layout older falcon saved, then Justified |
| Display switches and info type | Global, in local storage (eagle.list.show.*, eagle.list.meta.type, eagle.list.layout.options): name, info, extension, extension label and comment count on, Dimensions, Fit | Same defaults, kept for the whole app in falcon’s saved state (display) |
| Item info | Dimensions shows a video or audio file’s length, a font’s weight, a bookmark’s domain (or its length when it has one), and the size of a file without a preview or of a document, 3D model or design file; Tags shows the tags (a dash when none); Rating five stars; dates as yyyy/MM/dd HH:mm, the import time standing in for a missing file date | Same, with falcon’s date format |
| Captions | The name and info lines under Waterfall, Justified and Grid cards; a Justified row’s band (name and info) sits under its images, so the widths stay those of the images | Same (logic::place); hiding both removes the caption space, and Justified rows then sit a gap apart |
| Pin badge | An item pinned to the top of the open folder gets a small dark square with a pin in the top-left corner of its card (before the type label), only while that folder is open on its own (not with several folders picked together) and its own sort isn’t Random; the list layout shows it before the name | Same, drawn with a font icon (logic::pinned_here) |
| Broken thumbnail | A placeholder; hovering it says the file name or path is too long to preview (a thumbnail path of 260 characters or more) and to rename it or move the library to a shorter path, or that the file wasn’t found and to wait for syncing to finish when a sync tool is used | The same placeholder with a yellow warning mark in its corner, and a tooltip with falcon’s own wording for those two cases and a third for a file that exists but couldn’t be decoded. Only cards whose load failed get one; formats with no preview keep the plain label. The files are checked only for failed cards, and again after a few seconds (logic::broken_thumb_hint) |
| Show Subfolder Content | In Eagle’s preferences (showSubfolderContent), off by default. A folder then also lists the items of every folder under it (search and filters included), the subfolder strip lists every folder under it, and its cards can’t be dragged into order. The sidebar’s counts don’t change | Same, kept in falcon’s saved state (display). Manual drag ordering is off while it is on, since items from subfolders have no place in the folder’s order; a Manual sort still applies, those items falling back to their import time as in Eagle. The switch is also on the subfolder strip’s heading, in the View menu and on ⌥⌘7 |
| List columns | A header over the rows: Name, Tags, Dimensions, Rating, Type, File Size, Date Imported. A click sorts by the column (the open folder’s or smart folder’s own sort, else the global one), a second flips the direction; right-click picks which columns show (eagle.list.layout.settings: tags and rating hidden by default) | Same columns, order, defaults and clicks, kept in falcon’s saved state (display.columns); the arrow on the sorted column points down for an increasing sort, as Eagle’s |
| Thumbnail Speed / Quality | Loads originals for large tiles when set to Quality | Not built: falcon always draws the thumbnail |
Selection
Click selects one item, ⌘-click adds or removes one, ⇧-click selects the range from the last clicked item, and a click on empty space clears the selection.
Dragging over empty grid space draws a rubber band, in every layout, as Eagle’s rect-select does (seen): every card the band touches is selected, replacing the selection; with ⌘ held when the drag starts, each touched card flips (selected ones drop out); with ⇧, touched cards join the selection (Eagle keeps the selection when a ⇧ drag starts but drops what the band leaves as it moves, so ⇧ acts nearly like a plain drag there; falcon adds, as Finder does). Shrinking the band gives back what it no longer touches. Held within 24 px of the grid’s top or bottom edge (or past it), the band scrolls the grid. A drag that starts on a card still drags items. The rules are logic::marquee_selection and logic::rect_hits.
⇧→ and ⇧← extend the selection one item at a time, as Eagle’s multiple select does: the selected run grows at its end away from the anchor (the item last clicked or moved to), and shrinks back toward the anchor when it already reaches past it on the other side. ⇧↓ and ⇧↑ do the same in the List layout; in the other layouts they do nothing, as in Eagle (logic::extend_selection).
When the selected items leave the grid (moved to the Trash, restored or deleted from the Trash, removed from the open folder, or dragged onto another folder, which moves them out), the item after them is selected, or the one before at the end of the grid, as Eagle does. Items dragged into a subfolder the view shows (with Show Subfolder Content on) stay selected.
Hover preview
Hovering a card shows a faint zoom button in the bottom-right corner of its image (in the List layout, over the whole thumbnail). Resting on the button for 200 ms floats an enlarged copy of the image over the window, which dims behind it. Holding Z shows it at once for whichever card is under the pointer. The code is in crates/falcon-app/src/hover.rs.
What Eagle does, from its js/hover-preview.js, style/components/_hover-preview.scss and _box-container.scss:
| Question | Eagle | Seen or inferred | falcon |
|---|---|---|---|
| Trigger | Not the card itself: a zoom button (24 pt, 20 pt on small cards, growing on large ones, 20–32 pt) in the thumbnail’s bottom-right corner, or the whole thumbnail in List. It fades in to half opacity 250 ms after the card is hovered, and to full when hovered. The habits.hoverZoom preference (on by default) hides it | Seen | Same, without the 250 ms fade-in and with no preference yet |
| Delay | 200 ms on the button (defaultDelay, overridable per element with data-hover-delay) | Seen | 200 ms |
| Key | Holding Z shows the preview at once, and moving onto other cards switches it. Ignored with ⌘, ⌃ or ⇧, in text fields and in the viewer. Releasing Z hides it | Seen | Same |
| Anchoring | Anchored to the card, worked out once when it shows; it does not follow the pointer | Seen | Anchored to the card, recomputed each frame |
| Placement | Picks above/below or left/right, whichever lets the image be largest; ties go beside the card unless the card is partly off screen. Beside, it starts 15 pt inside the card’s near edge and may cover the card; above or below it is left-aligned with the card. Its top sits a quarter of the card (at most 20 pt) below the card’s top. Then it is pushed back inside the window, 10 pt from the edges | Seen | Same rule, rewritten (hover::preview_rect) |
| Size | As large as the chosen area allows, never larger than the original’s pixel size. An unused 480 pt cap appears in the code | Seen | Same; preview_rect takes an optional cap, unused |
| Image | Shows the thumbnail at once, then swaps in the original 50 ms later once it loads, for avif, gif, webp, png, jpg, jpeg, bmp, ico and jfif under 56 megapixels; an animated original plays | Seen | Shows the grid thumbnail, then the original (decoded at up to 3072 px) for the same types when the preview is larger than the 512 px grid decode. GIFs and animated WebPs play, decoded at the preview’s size (see GIF and animated images) |
| Look | 3 pt frame in the panel border colours, 6 pt corners, a drop shadow (0 12 24, 30% black) and a 1 pt dark ring; the window behind dims to 50% black; fades in over about 150 ms | Seen | Same, with a uniform frame colour |
| Other types | Video, audio, font and web-link cards have no zoom button (video and audio have their own hover playback, a separate feature). Holding Z still shows their thumbnail | Seen | Same; video and audio cards have the hover playback described below |
| Caption | None: only the image | Seen | None |
| Cancels | Leaving the button, leaving the card, releasing Z, clicking a card (a held Z must then be pressed again), opening the viewer, and the item list refreshing (search, filter, folder change) | Seen | Same, plus any pointer press, a drag, and scrolling (for the button; with Z held it switches to the card now under the pointer) |
| Scrolling | No explicit handler; the pointer leaving the card as the grid scrolls closes it | Inferred | Closes on scroll |
noPreview | Checked before showing, but nothing in the bundle sets it | Inferred unused | Not modelled |
Video and audio hover playback
From Eagle’s js/hover-preview/video-hover-preview.js and audio-hover-preview.js. Falcon’s state, rules and worker are hover_media.rs, the drawing ui/grid/hover_media.rs. It works in every layout (Grid, Waterfall, Justified and List) and on every page that lists items (folders, tags, smart folders, search, All): they all draw their cards in the one grid loop. In the List layout the preview fills the row’s thumbnail square, with the progress bar only (the speaker button and time pill would cover a square that small; the choice is kept, and a click on an audio row’s thumbnail still seeks).
| Aspect | Eagle | Status | Falcon |
|---|---|---|---|
| Video: start | The .thumbnail of a video card (not the name under it): a timer of 250 ms (the source comment says 500 ms, the code sets 250) and a muted, looping <video> of the original replaces the thumbnail; off when video.hoverPlay is "false"; skipped while a button is held or the marquee is out | Seen | The same trigger area, delay (hover_media::VIDEO_DELAY), preference (Prefs::video_hover, on by default) and skips. The card plays the file itself, looping, at its own frame rate: a worker thread reads the file (falcon_video::Prepared), and the main thread makes a falcon_video::Video whose output is scaled to the card (at most 640 px), which AVFoundation or FFmpeg decode on their own threads. The picture goes into one texture. Only the hovered card has a player; it is dropped when the pointer leaves, which ends its decoding, so memory stays at one picture and the player’s short queue. FFmpeg plays what macOS refuses, and takes over from a system player that fails or draws nothing in 1.5 s, also opening on a worker. A clip with no known length keeps its thumbnail. Originals still in iCloud aren’t downloaded for it |
| Video: scrub | Moving the pointer sets the time to its fraction across the picture and pauses; 200 ms after it rests, play resumes from there | Seen | Same (VideoHover::step): the player pauses and seeks exactly; a new seek waits for the last one’s picture, at most 120 ms (SeekGate), so the picture follows the pointer on clips with far-apart keyframes, and the last position always goes out |
| Video: bar and time | A progress bar along the bottom; the current time shows for 600 ms after each move | Seen | Same, with time_label (elapsed / total) in a dark pill |
| Video: sound | A mute toggle on the card, muted by default, remembered (eagle.list.video.muted); the sound is the video element’s own, so it follows the scrub position | Seen | A round speaker button at the bottom right of the picture, above the progress bar (tooltip Mute / Unmute). Muted by default; the choice is kept in the grid options (Display::video_muted, default true) for all later previews. Clicking it is the button’s own click (it doesn’t select or open the card), and the card’s preview goes on while the pointer is on it. The sound is the player’s own, at the viewer’s video volume, so it follows the scrub and stops with the preview; only one card plays at a time, and an audio card’s hover ends the video’s in the same frame. FFmpeg’s sound goes to the hover player’s output, which is opened the first time a preview that may use FFmpeg is unmuted, not before |
| Video: which soundtracks | Chromium’s codecs, then the MPV fallback | Seen | Whatever plays the picture: AVFoundation’s codecs, and when the FFmpeg libraries are installed (Video playback) those of FFmpeg’s build, for Matroska, WebM, AVI, WMV, FLV, TS and 3GP. Without FFmpeg those keep their thumbnail, there being no player for them; there is no message. Not yet heard by a person: the sound path is the viewer’s, and the output device has only been exercised headless |
| Audio: start | 200 ms after entering the thumbnail, the track plays at the player’s volume, unless the card’s play button was last switched off (listAudioAutoPlay, remembered) | Seen | Same, with the hover player so the viewer’s isn’t disturbed; the switch is kept in the grid options (Display::audio_autoplay) |
| Audio: progress | The played part of the waveform is tinted, a line follows the pointer, a press seeks there, the time shows, and the track loops | Seen | Same: a primary-tinted band and playhead line, a pointer line, click to seek, the time in a pill, and looping |
| Audio: stop | Leaving the card, dragging it, or hiding the window stops it; a card scrolled out of view is cleaned up | Seen | Checked at the end of every frame that draws the grid (HoverMedia::end_frame): a card not hovered in it (the pointer left the thumbnail, the card scrolled away, a drag began or a button was pressed elsewhere) ends its preview and sound in that frame. When the grid isn’t drawn at all (the viewer opened) the next frame’s begin ends it. Hiding the app (⌘H, or the red button and ⌘W, which hide it as in Eagle) and minimizing the window silence the hover sound and pause the video preview at once from AppKit notifications (dock::on_hidden), since no frames are drawn while hidden, and the next frame ends the previews (HoverMedia::stop_all) |
| GIF and animated WebP | Hovering the .thumbnail swaps in the original at once, not with ⌥ held; habits.alwaysPlayGIF plays all | Seen | Same trigger area (App::on_thumb) in every layout |
Tests: the state machine (delay, scrub mapping, rest and resume, loop, leave ends the preview in its frame, the sound plan) is in hover_media.rs, the toggles’ persistence in logic/display.rs, and the decoders per container in falcon-library/src/audio/tests.rs. The Matroska and WebM cases need the FFmpeg libraries: run them with FALCON_FFMPEG_DIR=<path>/target/ffmpeg/lib, else they only check that the open fails with the reason.
Preferences
⌘, (or Preferences… in the app menu) opens a window over the main one, laid out like Eagle’s: a search box above the list of panes on the left, the pane on the right, and Save, Apply and Cancel at the bottom. The panes follow Eagle’s order, with separators between groups: General, Sidebar | Controls, Preview, Screenshot, Shortcuts | Notification, Password, Auto-Import | AI Search, AI Models, Eagle MCP | Developer. Eagle’s Proxy pane is not built; a pane appears only once it has a working setting. Each pane is one function in crates/falcon-app/src/prefs.rs that adds titled blocks to a Page.
Typing in the search box shows the matching blocks of every pane under the pane’s name. A block matches when every word typed appears in the pane name, the block title or its keywords (case-insensitive), as Eagle’s blocks carry keyword lists. The Shortcuts pane appears in the results as a button that opens it. The last pane shown is remembered across launches, as Eagle remembers it.
Edits stay in a draft until Save (also ⌘↩) or Apply; Cancel, the close button and Esc drop them, as in Eagle. A previewed theme, interface zoom or language reverts on Cancel. The values are saved with the rest of the app state.
| Section | Setting | Eagle | falcon |
|---|---|---|---|
| General | Theme: Auto, Light, Lightgray, Gray, Dark, Blue, Purple | theme ({name, color, css}), default DARK; Auto is {name: "Auto", css: "auto"} | Stored as theme under Eagle’s css names (auto, lightgray, …); picking one previews it at once, Cancel or Esc switch back |
| General | Interface zoom: 50, 75, 80, 90, 100, 110, 125, 150% | general.zoom, default 100, options 50–200% | Same list up to 150%, default 100%; previewed at once (egui zoom factor), reverted on Cancel, applied at launch. egui’s own ⌘+ and ⌘− UI zoom is turned off, since falcon uses those keys for the grid |
| General | Language: Automatic, English, 简体中文, 繁體中文, 日本語, 한국어, Deutsch, Español, Русский | general.language, empty by default (the system’s language); its i18n/ folder has the same eight | The same eight with falcon’s own translations, stored as language (auto or a code such as zh_CN). Automatic takes the first of macOS’s preferred languages that falcon has, else English. Previewed at once, menu bar included, and switched back on Cancel. Every pane, menu, dialog, toast and viewer follows it (see Localisation) |
| General | Show item counts in the sidebar | general.showSidebarBadge, on | Same; off hides every count beside sidebar rows (nav rows, Quick Access, smart folders, folders) |
| General | Show the sidebar, show the inspector | View menu toggles, not preferences | Same flags as the panel buttons |
| General | Make menus, dialogs and the Preferences window translucent | general.enableVibrancy (its transparency checkbox), on by default | Prefs::vibrancy, off by default (falcon’s menus and dialogs can’t blur what is behind them as Eagle’s do); macOS only. See Transparency effects |
| General | Open Falcon when you log in | Eagle’s login item option | A Startup block. Registers the app with SMAppService (login_item.rs, macOS 13 or later). The state is not saved in the preferences: the system keeps it, and it is read each time Preferences opens, so a change made in System Settings ▸ General ▸ Login Items shows. Save or Apply registers or unregisters it, and a failure shows as an error toast with the system’s message. When the system wants the user to allow it, a note points to Login Items. It only works from the app bundle (scripts/bundle.sh); under cargo run, on older macOS and on other systems the checkbox is disabled with the reason. The older LSSharedFileList API is not used |
| General | Keep Falcon in the Dock after closing its window | Eagle’s Dock icon option, on by default | Prefs::keep_dock_icon, on by default and on for older saved state. On, closing the window hides the app and the Dock icon brings it back. Off, closing the window also takes falcon out of the Dock while the menu bar icon is shown (its Show falcon, a global key or opening the app again puts it back); with the menu bar icon off nothing could bring it back, so closing the window quits instead (pending saves are written first) |
| General | Select the new items after importing | general.autoSelect, on by default. When a batch finishes, the new items shown in the open view are selected (not in the viewer or a Random view, and only up to 1,000); a single new item is scrolled to, also with the setting off | Same rule, applied when falcon’s batch finishes rather than per Eagle refresh |
| General | Ask what to do when an imported file is already in the library | notification.notification.when.repeatImage (Notification ▸ Notify me when importing duplicates), on by default, and only while the notification master switch is on | On by default; when off, such files are imported as new items without asking, as in Eagle (see Duplicate check on import). Also shown in Notification, as Eagle shows it in both, and effective only while Notification ▸ Show notices is on, as in Eagle |
| General | Add IPTC and XMP keywords as tags | general.IPTC, off by default | Same: every import from the app (drops, ⌘V, files passed to the app, the collect window) reads it (see IPTC keywords) |
| General | Show the falcon icon in the menu bar | general.showMenuItem, on by default, macOS only | Same, on by default (Prefs::menu_icon); see Menu bar icon |
| General | Keep full Retina resolution in screenshots (@2x) | screencapture.useRetina, on by default, shown in General and in Screenshot | Same setting (Prefs::screenshot_retina), shown in both panes; off halves screenshots taken on a scale-2 screen |
| General | Search: also find near misses | None: Eagle’s search matches exact substrings only | falcon’s own (Prefs::typo_search), on by default: words within one or two typos are listed after the exact matches. Off, results match Eagle’s (see Typo tolerance) |
| Sidebar | Double-clicking a folder’s name renames it, or expands or collapses it; on its icon or count badge it always expands or collapses (index.html) | habits.dblclickSidebarItem, collapse by default | Same, for folders and smart folders |
| Sidebar | Sections: Uncategorized, Untagged, Recently Used, Random, Quick Access, Smart Folders, Folders | sidebar.unfiled, untagged, recent, random, quickAccess, smartFolder, folder (and community2), all on | The same seven, all on by default. Community has no counterpart; All, All Tags, Duplicates and Trash are always shown. As in Eagle (openSidebarVisibleContextMenu, seen), right-clicking any of the sidebar’s fixed entries but Trash and Recently Used (which have menus of their own) opens a menu of tick boxes for the first four, with All ticked and greyed; it stays open while you tick |
| Controls | Zoom button on hovered cards (hover zoom) | habits.hoverZoom, on by default, under Controls ▸ Mouse; off removes the zoom button, but holding Z still works | Same |
| Controls | Double-clicking an item opens it in falcon, or in its default app | habits.doubleclick, internal | Same; the default app is opened with open |
| Controls | Middle button | habits.middleBtn, opens a new window by default | Same (Prefs::middle_button): a middle click on a card opens it in the preview window (not audio), or does nothing; no plugin choice |
| Controls | Space in the grid opens the viewer, shows Quick Look, or scrolls down a page | habits.keyspace: preview (default), preview-native, scroll | The same three. Quick Look runs qlmanage -p on the selected originals (macOS only). It applies to whatever key the “Open or close the viewer” shortcut uses. Preview and Quick Look need a selection (several items preview the first), as in Eagle; scrolling a page needs none. In the viewer, Space still closes it or plays and pauses media |
| Preview | Video: scrolling over a video moves through it, or changes the volume | habits.videoScrollBehavior, progress | Same; volume mode moves 5% per wheel notch, up is louder |
| Preview | Stretch videos to fill the viewer | video.zoomFill, off | Same: on enlarges past the video’s own size to fit the stage |
| Preview | Play videos when they open | video.autoPlay, on | Same |
| Preview | Resume where each video was left | video.rememberPosition, on; positions kept per item in local storage | Same; positions are saved per item id in the app state (the latest 500), not in the library |
| Preview | Always loop clips of 30 s or less | video.loopShortVideo, on | Same |
| Preview | Add style tags (Regular, Bold, Italic) when importing fonts | font.autoTag, on by default, in Eagle’s Preview pane | Same; sets ImportOptions::font_weight_tag for every import from the app |
| Screenshot | Format: PNG, JPG, WebP | screencapture.format, png | Same (Prefs::screenshot_format); the picture is converted from the PNG screencapture writes |
| Screenshot | Quality: High, Medium, Low | screencapture.quality, '90' (High 90, Medium 80, Low 70), shown only for JPG and WebP | Same three values and default; shown only for JPG and WebP. A saved value that is not one of the three goes back to 90 |
| Screenshot | Use the screenshot keys | screencapture.shortcutsEnable, on by default | Off by default (Prefs::screenshot_shortcuts), unlike Eagle, because Eagle often runs on the same Mac and holds ⌥⌘E. The three capture keys are registered with the system only while it is on; turning it on gives Eagle’s layout, since the area key keeps ⌥⌘E. A key another app holds says “⌥⌘E is taken by another app” on its row |
| Screenshot | Screenshot of an area, of a window, of the screen | global.capture.area (CmdOrCtrl + Alt + E), global.capture.window and global.capture.full (both empty) | The same three rows and defaults as the Global group of Shortcuts, shown again here to rebind them; ⌘⌥E takes an area, the others are unbound |
| Screenshot | Choose where each screenshot is saved | general.showCaptureCollectModal, off by default | Same (Prefs::screenshot_ask): an area or window screenshot waits in the collect window; a screen screenshot never does |
| Screenshot | Show a notification for each screenshot | notification.notification.when.screencapture, on by default | Same (Prefs::notify_capture), also in Notification; only for an area or window screenshot that goes straight into the library |
| Screenshot | Tag screenshots “Screenshot” | screencapture.autoTagging.enable, on by default | Same (Prefs::screenshot_tag) |
| Screenshot | Copy screenshots to the clipboard too | screencapture.autoWriteClipboard, off by default | Same (Prefs::screenshot_clipboard) |
| Screenshot | Keep full Retina resolution (@2x) | screencapture.useRetina, on by default | Same setting as in General |
| Shortcuts | Rebinding commands | shortcuts.keybinds | See Shortcut settings |
| Notification | Show notices, with: ask about duplicates; say how many items an import added; say when files from the auto-import folder start importing | notification.notification.enable gates its when.* items (screenshots, extension, repeatImage, autoImport) | Master switch, on by default. It gates the duplicate dialog and falcon’s own “Added N” status note after an import (falcon’s own event, on by default). The auto-import notice is Eagle’s when.autoImport, on by default. Import failures are always reported |
| Notification | Sound effects, with: delete item, delete folder, screenshot, extension | notification.soundEffect.enable gates when.deleteImage, when.deleteFolder, when.screencapture and when.extension, all 'true' by default; the four checkboxes show only while the master switch is on. No preview buttons | “Play sound effects” with “When items are deleted”, “When folders or tags are deleted” and “When a screenshot is added” under it (Prefs::sounds, sounds::SoundPrefs), all on by default and greyed out while the master switch is off. The extension switch is kept and reported by the API but not shown, since falcon has no browser extension. See Sound effects below |
| Notification | Screenshot and export notifications | when.screencapture (on) is Eagle’s screenshot pop-up; its “Export Successful” pop-up has no switch of its own that was found | “Show a notification for each screenshot” (notify_capture) and “Show a notification when an export finishes” (notify_export_done, falcon’s own), both on by default and under the master switch. They post system notifications; see System notifications |
| Password | Lock falcon with a password; change; remove; hint | privacy.enable, privacy.password (stored base64), privacy.passwordTips; Lock Now app.lock = ⌥⌘L; Touch ID | Set, change (needs the old password) and remove (needs the current one) in the pane, taking effect on Save or Apply. With a password set, falcon opens to a lock screen, and Lock Falcon (⌥⌘L, rebindable, also in the app menu, enabled only with a password) hides the window, stopping playback and closing Preferences and the collect window, until the password is typed; an optional hint shows on request. The password is stored in the app state as a salted, stretched BLAKE3 digest (applock.rs), never in a library, and is not reversible like Eagle’s base64. It guards the window only; library files and the HTTP API stay reachable. On a Mac that can use Touch ID, the pane shows Unlock with Touch ID (Eagle’s privacy.enableTouchID); turning it on asks for a fingerprint first and stays off if that fails, as Eagle’s does. The lock screen then has a Use Touch ID button under the password field; the password always works too. touch_id.rs asks LocalAuthentication’s LAContext (biometrics only, as Electron’s promptTouchID does) through msg_send! with a reply block. Eagle also offers Touch ID on locked folders; falcon doesn’t yet |
| Preview | Hover zoom | habits.hoverZoom, on by default; off removes the zoom button, but holding Z still works | Same |
| Preview | Video hover preview | video.hoverPlay, "true" by default | Same (Prefs::video_hover), in the Video block |
| Preview | Always play GIFs and animated WebPs in the grid | habits.alwaysPlayGIF, off by default; hovered cards play either way | Same (always_play_animated) |
| Preview | Always use the frame-by-frame player for GIFs | habits.gifViewer, off by default | Same (gif_player) |
| Controls | The wheel over an image in the viewer scrolls, zooms or goes to the previous or next item | habits.scrollBehavior, scroll by default (the others are zoom and paging). Scroll pans the picture; ⌘, ⌃ or ⌥ with the wheel zooms in scroll and zoom mode; paging steps once the wheel has rested 50 ms. Eagle asks the first time the wheel is used in the viewer | Same three modes and default (logic::wheel_action); in paging mode a pinch or ⌘/⌥ wheel still zooms. Scrolling stops at the picture’s edges (logic::clamp_pan). No first-use prompt |
| Preview | Open images at actual size instead of fitting them | habits.defaultRatio, auto (Smart Zoom) by default, or 100%. Zoom to Fit still uses Smart Zoom | Same |
| Preview | Sharp pixels when an image is enlarged | habits.renderBehavior, non-pixelated by default; pixelated turns smoothing off from 200% up (also on grid thumbnails) | Off by default. When on, the viewer’s full render and full-resolution tiles are drawn with nearest-neighbour sampling from 200% of the image’s size up (logic::pixelated_at), in the viewer and in each preview window, each on its own loader. egui fixes sampling when a texture is uploaded, so crossing 200% makes the render again in the background while the old one stays on screen. Grid thumbnails stay smooth |
| Preview | Reopen an image at the zoom and position it was left at | habits.rememberLastZoom, on by default; kept in memory (lastItemStates), skipped in fit-to-edge mode | Same: per item, for the session, not in fit-to-edge mode |
| Preview | Checkerboard behind transparent areas | habits.transparency, hide by default | Off by default; when on, a white and light grey checkerboard of 8 pt squares is drawn under the picture |
| Auto-Import | Import files added to a folder automatically; Choose Folder…; Open Folder | autoImport.enable (off) and autoImport.path (empty). Turning it on with no folder opens the folder picker, and cancelling it leaves it off; a folder on a network drive is refused with a warning; Open the folder opens it in Finder when it exists. A saved folder that no longer exists is forgotten and auto-import turned off | Same defaults and rules (prefs::auto_import, auto_import::is_network_volume). The pane also says which folder the shown library watches (see Auto-import). Eagle deletes each file after importing it; falcon has a setting for that, “Delete files after importing them” (Prefs::auto_import_delete), off by default, which when on moves the file to the Trash once the item is in the library and shows a red warning while it is on. Off, files stay and falcon remembers them |
| Eagle MCP | Run the MCP server, first port, allow writes, require the token | Eagle’s pane installs or opens its MCP server plugin | Built in and off by default; see Eagle MCP |
| Developer | Run the HTTP API, first port | Eagle always serves on 41595 and only has an API token setting | On by default, from 41596; tries five ports. Ports whose range would include 41595, or below 1024, are refused. Changing either restarts the API |
| Developer | API token | Eagle 4.0.0’s preferences show, copy and regenerate an API token (developer.apiToken, empty by default; Regenerate makes a random UUID). Its JSON server lets a request carrying it in from any Host (observed in json-rest-light) | Empty by default, as in Eagle, and a token made by an earlier falcon version is kept. Empty, the pane says “No token. Only requests addressed to localhost are served.” and shows a Generate button; set, it shows the token with Copy, Regenerate and a ?token= link. A token that is only whitespace counts as empty. With a token set, a request carrying it is served from any Host; with none, only the Host check applies and no token value lets another Host in. See HTTP API |
One Eagle setting has no counterpart in falcon: GPU acceleration, because falcon always draws with wgpu (eframe’s glow backend is not compiled in, and the 3D viewer needs wgpu), so there is nothing to switch to. Color Space is in the App menu (see above) and sets the window’s CAMetalLayer colour space; falcon’s pixels are still sRGB content.
What Eagle does, from js/default-preferences.js, preferences.html, js/preferences.js and css/preferences.css (all seen):
- Preferences live in one tree with groups
general,habits,shortcuts.keybinds,theme,notification,sidebar,screencapture,video,proxy,privacy,autoImportanddeveloper. Booleans are stored as the strings'true'and'false', and choices as strings (hoverZoom: "on"). - The window’s sidebar (200 pt wide, 32 pt rows) has General and Sidebar, then Controls, Preview (the
habitsgroup), Screenshot and Shortcuts, then Notification, Password and Auto-Import, then the AI panels. A search box filters every panel by keywords. - Save applies and closes, Apply applies and stays open, Cancel and Esc revert (including a previewed theme or zoom), ⌘↩ saves.
- The window’s own size is set in Eagle’s main process, which is not in the readable bundle; falcon’s 720 × 500 pt is a guess (inferred).
- Built in falcon since: the menu bar icon option, the collect modal for screenshots, and screenshots with their keys, format, quality, tag, clipboard and @2x settings.
- Built in falcon since: sound effects (see below).
- Not built in falcon: GPU, proxy.
AI Models
Preferences ▸ AI Models is one place to add AI providers. Every AI feature in falcon uses them through the falcon-ai crate, as Eagle’s AI plugins share its ai-sdk. Falcon’s copy is its own: it uses the same provider ids and provider::model names and never reads Eagle’s plugin data.
| Provider | Id | Kind | Key | Base URL when left empty |
|---|---|---|---|---|
| OpenAI | openai | Cloud | Required | https://api.openai.com/v1 |
| Anthropic | anthropic | Cloud | Required | https://api.anthropic.com/v1 |
| Google Gemini | google | Cloud | Required | https://generativelanguage.googleapis.com/v1beta |
| DeepSeek | deepseek | Cloud | Required | https://api.deepseek.com/v1 |
| Tongyi Qwen | tongyi | Cloud | Required | https://dashscope.aliyuncs.com/compatible-mode/v1 |
| Ollama | ollama | Local | None | http://localhost:11434/v1 |
| LM Studio | lmstudio | Local | None | http://localhost:1234/v1 |
| OpenAI-compatible | openai-compatible | Custom | Optional | None; it must be typed |
No provider is added at first, and falcon sends nothing until one is. Even then a request goes out only when you press Verify or run an AI step; nothing is fetched at launch or in the background.
- Add Provider adds a box with the key field, the base URL and Verify. Remove takes the provider out, clears any default model that names it, and removes its stored key.
- Verify asks the provider for its model list, off the UI thread, with the key as typed or else as stored. It says how many models there are or why it failed, and fills the list that Default Models offers. Cancel stops it.
- Default Models holds the chat and vision model as
provider::model, typed or chosen from the fetched lists. The name splits at the first::, soollama::qwen3.8:27bis a valid name. - As with the other panes, the settings stay in the draft until Save or Apply, and a typed key waits with them. Save or Apply writes it to the Keychain; Cancel forgets it.
Keys are kept in the macOS Keychain as generic passwords: service Falcon AI Models, account the provider id. They are never in the library, the saved preferences, a log or an error text. An error from a provider has the key taken out and is cut to 300 characters. A key goes in a request header, never in a URL, and only over https or over http to this Mac. A base URL with a user name, query or fragment is refused.
The interface (crates/falcon-ai) offers generate_text, stream_text, generate_object (a reply checked against a JSON schema) and images as parts of a message. Six providers speak the OpenAI chat format; Anthropic and Gemini have their own. DeepSeek and Tongyi get json_object with the schema in the instructions; Anthropic gets one forced output tool; every reply is checked against the schema in falcon either way. Requests to Ollama turn off a thinking model’s reasoning (reasoning_effort: "none").
Calls block their caller, so features make them off the UI thread and never hold the library lock meanwhile. Each takes a cancel flag and has timeouts for connecting, the first piece of the reply, the gap between pieces and the whole call: 2 minutes, 1 minute and 5 minutes for cloud providers, and 10, 3 and 20 minutes for local and custom servers, which may have to load a large model first. Replies are always streamed, so a slow model isn’t mistaken for a dead one. Redirects are refused, so neither the key nor the items can be passed on to another host.
Only the Ollama path has run against a real service. The other providers are tested against a local mock server.
The first feature on this interface is the AI steps in Actions: auto-tag and write a description.
Transparency effects
Eagle stores general.enableVibrancy, 'true' by default, and sets a vibrancy attribute on each window’s root element (js/default-preferences.js, js/preferences.js, preferences.html, index.html, seen). In its main window on macOS the attribute only changes --box-background and --box-background-blur, the blurred background of menus, boxes and the sidebar when it slides in on hover (style/base/_color.scss, style/components/_sidebar.scss, seen); the translucent --color-theme-75 page background is commented out in style/app.scss and live only in Preferences and two smaller windows (style/preferences.scss, seen). How its main process configures the Electron window is in compiled bytecode and not read.
Falcon’s checkbox, in the General pane’s Window block, moves as much of this as egui allows onto the same surfaces: menus and boxes go translucent and the main window stays opaque. It is off by default, as falcon’s boxes can’t blur what is behind them. vibrancy.rs holds the rules:
- Context menus, popups, the type-to-filter menus, tooltips, dialogs, the tag picker and the comment editors are filled at 94% (
Surface::Box, through egui’swindow_fill). They show the window’s content behind them, unblurred. egui can’t draw outside its window, and a blur of the content (what Eagle’s CSSbackdrop-filterdoes) would take a second render of the frame, so these boxes are translucent but not blurred. Tooltips are included because they share egui’s popup frame; whether Eagle’s are is not known. - Preferences and the Collect window, which are windows of their own, show the real desktop blur: an
NSVisualEffectView(Sidebar material, blending behind the window, always active because the window floats over other apps, light or dark after falcon’s theme) sits under wgpu’sCAMetalLayerin a transparent window, and the window is filled at 75%, the opacity of Eagle’s--color-theme-75(Surface::BoxWindow). In Preferences the pane on the right keeps its opaque background, so only the navigation column shows the blur. Falcon finds its NSWindow by title, as it does for a preview window’s opacity. - The main window and preview windows stay opaque. The main window is created transparent only because eframe fixes the surfaces’ alpha mode from it; it is made opaque every frame and cleared to an opaque colour, and egui’s blending never lowers alpha already in the frame, so a screenshot or capture of it comes out as with the preference off.
- The menu bar is AppKit’s and the system makes it vibrant whatever the preference says.
- The switch shows at once while Preferences is open, Cancel takes it back, and Save or Apply keeps it, all without a relaunch.
Preferences is a separate window with or without the preference, as it is in Eagle. The main window waits behind it, dimmed, and a click there brings Preferences back to the front. Esc, ⌘W and the red button cancel, as Cancel does.
Sound effects
What Eagle does (seen in app.bundle.js, js/background.js, progress.html and js/vendors/jquery-audio.js):
- It ships four short sounds: one for removals, one for duplicates, one for errors and a notification chime. Every sound goes through one player with a 500 ms leading-edge debounce shared by all of them: a call within 500 ms of the previous call is dropped and restarts the wait. Sounds play through the system output, so the Mac’s volume and mute apply.
- The removal sound plays under
when.deleteImagefor Move to Trash, Remove from Folder and deleting a folder’s contents, and underwhen.deleteFolderfor deleting folders and smart folders, deleting tags, taking tags out of a tag group, deleting items for good and emptying the Trash. One action on many items plays it once. - The screenshot sound plays when the screenshot has become an item, not when the shutter fires. A full-screen shot plays it directly; an area or window shot plays it with its “screenshot added” pop-up, so it is silent when pop-ups or their screenshot switch are off, and silent when the collect window opens instead.
when.extensioncovers the browser extension’s saves and screenshots. The duplicate dialog, the first failed import of a batch, the auto-import notice and items added through the API with a notice follow only the master switch.
How falcon does it (crates/falcon-app/src/sounds.rs, app/sounds.rs):
- The sounds are falcon’s own, synthesised on first use from short envelopes; no audio files ship. Delete item is a soft filtered-noise whoosh (180 ms), delete folder two lower tones (240 ms), the screenshot a two-click shutter of bright noise (150 ms), duplicates two short blips, a failed import a low tone and the auto-import notice two rising notes. Each peaks at about a third of full scale.
- They are mixed into the viewer’s rodio output stream (
Audio::play_clip), which stays open for the session. If no sound or track has opened it yet, the first sound opens it on a worker thread and plays from there, so the window never waits; only an output device is ever opened. The viewer’s volume slider doesn’t affect them; the Mac’s volume and mute do. - The 500 ms debounce is Eagle’s, shared by every sound; a call the switches refuse doesn’t count.
- Every item and folder delete goes through the action reducer (
app/actions.rs), which plays the matching sound once the change is saved: from the grid, the inspector, the context menus, the Edit menu, the keyboard, the viewer and the confirm dialogs alike. Move to Trash that asks first plays when it trashes; Empty Trash plays when it finishes; restoring is silent. Deleting a tag group plays nothing, as in Eagle. - Screenshots follow Eagle’s rule (
Prefs::sounds_capture) for every capture mode, whether started from a global key, the menu bar icon or a menu.screencapturenow always runs with-x, so the system shutter sound never plays over falcon’s. - The duplicate dialog opening, an import batch ending with failures and the auto-import notice play their sounds under the master switch alone. falcon has no browser extension, so nothing plays for
when.extension, and the HTTP API’s adds post no notice and play nothing.
Themes
Eagle has seven theme choices: Auto, Light, Lightgray, Gray, Dark, Blue and Purple, in that order in js/preferences.js (observed). falcon offers all seven in the same order. The code is in crates/falcon-app/src/theme.rs:
Themeis the choice.Paletteholds one theme’s colours.p()returns the current palette.set_themeswitches it and restyles egui.follow_systemruns once a frame and keeps Auto in step with macOS.
Auto (observed in js/preferences.js and app.bundle.js). Eagle stores Auto as {name: "Auto", css: "auto"} and never puts auto on body. Wherever it needs the theme, it asks Electron’s nativeTheme.shouldUseDarkColors: true shows gray, false shows light (not dark, and not lightgray). The Preferences window listens for nativeTheme’s updated event, and the main window recomputes it when the theme changes.
falcon resolves Auto the same way, from egui’s system theme (Context::system_theme, filled from winit’s ThemeChanged and the event loop’s appearance at start-up). When the appearance is not known yet, Auto shows gray, matching egui’s dark fallback. Under Auto, falcon sets egui’s theme preference to System, so the window is not pinned to an appearance and macOS changes reach egui (winit drops ThemeChanged while a window has its own appearance, and egui pins it for a fixed Light or Dark preference). egui’s dark style gets gray’s colours and its light style light’s, so egui swaps its widgets by itself, and follow_system updates the palette p() returns. Neither step asks for any permission. With a fixed theme follow_system is one atomic load.
Where the colours come from (observed in style/base/_color.scss). body[theme=…] sets --color-theme:
- dark
#18191c - gray
#37383c - blue
#0d1630 - purple
#1C1424 - light
--color-white(#ffffff; the map’scolor-theme-light #f3f4f6is not used by it) - lightgray
--color-theme-lightgray(#e3e4e6)
The four dark themes share one block, and light and lightgray share another (body[theme=light], body[theme=lightgray], and the light mixin in style/_mixins.scss covers both). So lightgray is light’s overlays, text, #0072ef primary and folder colours on a grey base:
| Variable | Dark family | Light and lightgray |
|---|---|---|
| foreground (text, hover and active overlays) | --color-lightgray #f8f9fb | --color-dark #2c2f32 |
--color-primary | #3297FF | #0072ef |
| borders (secondary) | white at 8% | black at 10% |
card ring (--box-item-border-color) | white at 5% | black at 10% |
| hover / active | foreground at 5% / 10% | foreground at 5% / 10% |
inputs (_toolbar.scss, _inspector.scss) | black at 20% | white at 50% |
sidebar (_sidebar.scss) | lightgray at 3% | dark at 3% |
| folder colours and “none” | #FF6667 … #FF99CC, #BDBEC0 | #E55245, #CC782F, #C29343, #548164, #0097A7, #3987BF, #8A67AB, #CC6699, #50555B |
The old per-theme stylesheets (css/style_<theme>.scss) differ only in $black-color, which feeds a few legacy classes. They play no part here.
Flattening (inferred). Each falcon colour is one of these layers, composited source-over onto the surface below it and rounded to opaque sRGB:
mainis the base.panelis the sidebar layer overmain.- Rows, buttons, dividers, fields and muted text sit over
panel. - The search field, card ring, card hover and disabled text sit over
main.
Which layer stands for which falcon colour is my reading, not something the stylesheets say. It assumes vibrancy is off. The same model applied to dark reproduces every colour sampled from Eagle’s window to within 2 per channel. The script is derive.py in the session scratchpad.
A worked example, gray’s selected row:
- panel = 3%
#f8f9fbover#37383c= 0.03 × (248, 249, 251) + 0.97 × (55, 56, 60) = (60.8, 61.8, 65.7), which rounds to#3d3e42. - selected row = 10%
#f8f9fbover that = 0.10 × (248, 249, 251) + 0.90 × (61, 62, 66) = (79.7, 80.7, 84.5), which rounds to#505155.
The same for lightgray, whose foreground is --color-dark #2c2f32 = (44, 47, 50) on #e3e4e6 = (227, 228, 230):
- panel = 0.03 × (44, 47, 50) + 0.97 × (227, 228, 230) = (221.5, 222.6, 224.6), which rounds to
#dedfe1. - selected row = 0.10 × (44, 47, 50) + 0.90 × (222, 223, 225) = (204.2, 205.4, 207.5), which rounds to
#cccdd0. - field = 50% white over the panel = 0.5 × (255, 255, 255) + 0.5 × (222, 223, 225) = (238.5, 239, 240), which rounds to
#efeff0; the search field is 50% white over main,#f1f2f3. - muted text = 60%
#2c2f32over the panel = (115.2, 117.4, 119.4), which rounds to#737578. On the selected row it reaches 2.9:1, a little under the 3:1 falcon’s tests ask of the other themes, so the test allows 2.8 on rows and buttons.
Only dark was checked against Eagle’s window. The other five were not sampled from a running Eagle, because switching its theme would mean changing Eagle’s preferences and taking screenshots. Treat their colours as stylesheet-derived. Dark keeps its sampled values, which is why it differs slightly from the model (for example, main #17191d against the stylesheet’s #18191c).
| Constant | Dark (sampled) | Light | Lightgray | Gray | Blue | Purple | Layer |
|---|---|---|---|---|---|---|---|
MAIN | #17191d | #ffffff | #e3e4e6 | #37383c | #0d1630 | #1c1424 | --color-theme (observed) |
PANEL | #1f2023 | #f9f9f9 | #dedfe1 | #3d3e42 | #141d36 | #231b2a | sidebar 3% over main |
DIVIDER | #303134 | #e0e0e0 | #c8c9cb | #4d4d51 | #272f46 | #352d3b | border over panel |
ROW_SELECTED | #353639 | #e5e5e5 | #cccdd0 | #505155 | #2b334a | #38313f | active 10% over panel |
ROW_HOVER | #2a2b2e | #efefef | #d5d6d8 | #46474b | #1f2840 | #2e2634 | hover 5% over panel |
FIELD | #19191d | #fcfcfc | #efeff0 | #313235 | #10172b | #1c1622 | input over panel |
FIELD_BORDER | #2c2d30 | #e3e3e3 | #d7d7d8 | #414245 | #232a3c | #2e2934 | border over field |
SEARCH | #131416 | #ffffff | #f1f2f3 | #2c2d30 | #0a1226 | #16101d | input over main |
BUTTON | #2b2c2f | #efefef | #d5d6d8 | #46474b | #1f2840 | #2e2634 | hover 5% over panel |
BUTTON_HOVER | #343538 | #e5e5e5 | #cccdd0 | #505155 | #2b334a | #38313f | active 10% over panel |
CARD_BORDER | #242528 | #e6e6e6 | #cccdcf | #414246 | #19223a | #27202f | card ring over main |
CARD_HOVER | #3a3b3e | #dfe0e0 | #c8c9cb | #545559 | #30384e | #3d3644 | foreground 15% over main |
GUIDE | #37383b | #e0e0e1 | #c9cacd | #505155 | #2d354b | #3a3341 | “none” colour 15% over panel |
TEXT | #f8f9fb | #2c2f32 | #2c2f32 | #f8f9fb | #f8f9fb | #f8f9fb | --color-text-primary (observed) |
TEXT_MUTED | #a2a2a4 | #7e8082 | #737578 | #adaeb1 | #9da1ac | #a3a0a7 | text 60% (-tertiary) over panel |
TEXT_VALUE | #cdced0 | #55575a | #505255 | #d3d4d6 | #cacdd4 | #cdcdd1 | text 80% over panel |
TEXT_DISABLED | #5a5b5e | #c0c1c2 | #acaeb0 | #717275 | #535a6d | #5e5965 | text 30% over main |
PRIMARY | #3297ff | #0072ef | #0072ef | #3297ff | #3297ff | #3297ff | --color-primary (observed) |
BRAND | #0072ef | #0072ef | #0072ef | #0072ef | #0072ef | #0072ef | --color-brand (observed) |
ICON_RED … ICON_PINK, ICON_NONE | dark set | light set | light set | dark set | dark set | dark set | the two $colors maps (observed) |
How the code reads colours. Drawing code asks for the current palette with t::p() (t::p().main, t::p().text_muted, …), a static lookup that is cheap enough per card. The pub const colours in theme.rs are the dark values and only seed the dark palette. A few greys that used to be hard-coded for a dark background (the toolbar icon tint, the zoom slider, list zebra rows, the type placeholder, swatch rings and the delete-button red) now read the palette too, so they stay readable on light. Overlays drawn on images, on the primary colour or on black scrims keep their fixed white, as they do not sit on a theme surface.
Shortcut settings
Preferences → Shortcuts lists every rebindable command by group, with its chord. The first group, Global, holds the keys that work from any app (Show falcon, Show falcon and focus the search, and the three screenshots); see Global shortcuts. A search field at the top filters the list by command name, group, id or chord (and the fixed keys by what they do). Clicking a chord starts recording: the next key press becomes the shortcut. Backspace alone clears it, Esc alone stops recording, and a letter, digit or symbol needs a modifier (function keys, Space, ↩, arrows, Home, End, Page Up/Down, Delete and brackets may stand alone). A chord another command already uses is refused with “Used by …” for 1.5 s, and the old chord stays. A changed row has a reset button, and Restore Defaults (after a confirmation) resets them all. The last group, Player, holds the video player’s keys (play or pause, previous and next frame, back and forward 10 frames, volume, skip, speed and add comment). They act only while a video plays in the viewer, so, as in Eagle, they conflict only with each other: the player may use ⌘F, and its Space overrides the viewer’s while a video plays. Player chords must match their modifiers exactly (⇧[ is not also [). A command can also have extra chords that work only while its binding is still the default: ⌘← and ⌥← for Back, F2 for Rename, Enter for the viewer, and * (⇧8) for expand or collapse all folders. Because Backspace alone clears a binding in the recorder, bare ⌫ can’t be recorded for Back; the reset button brings it back. When a newer falcon’s default lands on a chord the user had already given another command, the saved chord wins and that default is left unbound (its extra chords still work if free). The table, the parser and the recorder rules are in crates/falcon-app/src/shortcuts.rs.
Changes are saved as a map from command id to Eagle’s accelerator text, for example {"edit.rename.darwin": "Command + R"}, holding only the changed commands; an empty string means unbound. Ids reuse Eagle’s keybind names where Eagle has the command (file.create.folder, view.zoom.in, find.filter.toggle, app.preferences, app.lock (Lock falcon, ⌥⌘L), player.prev1frame, player.step.forward, …). The add-comment key N gets falcon’s own id (player.comment.add): Eagle handles N outside its keybind table, so it can’t be changed there. falcon has no thumbnail keys (player.thumbnail.*). When a key press matches several chords (⌘F and ⌘⇧F for ⌘⇧F), the one with the most modifiers wins. ⌘C, ⌘X and ⌘V reach egui as Copy, Cut and Paste events rather than key presses, so falcon maps them back; this also makes ⌘C in the grid work, which the old hard-coded check missed.
What Eagle does, from js/default-preferences.js, js/preferences.js, js/services/shortcut-manager.js and js/directives/shortcut-input.js (all seen):
shortcuts.keybindsmaps 150 names (file.create.folder,view.zoom.in, …) to Electron accelerators such asCmdOrCtrl + Shift + N. On load,CmdOrCtrlis rewritten toCommand(macOS) orCtrl. Platform pairs such asedit.rename.darwin(⌘R) andedit.rename.win32(F2) show only on their platform.- The recorder writes modifiers in the order Ctrl, Command, Shift, Alt, then the key; letters and digits need a modifier; Backspace alone clears.
- Conflicts are checked within a group’s
conflictscope: player keys only clash with other player keys, everything else with everything else. A clashing chord is refused, the old one stays, and a note names the other command for 1.5 s. - Restore Defaults asks first, then reloads the defaults (plugin shortcuts too). The window has a shortcut search box; falcon has one too.
- Every falcon command that has an Eagle counterpart is listed under Eagle’s id, with Eagle’s macOS default (empty is unbound): the macOS-only ids carry their
.darwinsuffix (edit.rename.darwin,edit.remove.trash.darwin,edit.remove.folder.darwin,view.reveal.darwin,view.duplicate.darwin). Overrides saved by earlier versions underedit.rename,edit.remove.trashandedit.remove.folderare moved to the new ids when the app state loads (an override already saved under the new id wins). The defaults differ from Eagle’s table in two places on purpose: the rating rows (organize.rating.*) default to the bare digits 0–5 that Eagle hard-codes, and the layout rows follow Eagle’s keys rather than its swapped names. Eagle ids with no falcon action have no row: the Windows variants, the Pinterest and ArtStation importers, reverse image search, Merge Images and the semantic and image filters (Eagle’s AI Search plugin). Commands without an Eagle key stay menu-only: Undo, Redo, Cut, Import Files, Find Duplicates, Zoom, Documentation, About, Error Logs and Debug Report. - falcon uses ⌘R for rename, Eagle’s macOS default, and keeps F2 as an extra chord. It keeps its own ids for commands Eagle lacks or names differently (
view.viewer,go.back,edit.select.all,edit.folder.arrange.*) (inferred mapping). Eagle’sview.layout.gridandview.layout.waterfallare attached to Waterfall and Grid respectively, while falcon’s ids of the same names mean its Grid and Waterfall layouts; only the defaults follow Eagle (⌥1 Waterfall, ⌥3 Grid).
Collect window
⌘⇧K, or Show Collect Window in the library menu, opens a small (300 × 600 pt) borderless window that floats above all other apps. Files dropped on it, or pasted into it with ⌘V (copied files or image data), go through the normal background import into the folders picked in its list.
- Folders: a click picks just that folder; ⌘-click adds a folder to the pick or takes it out. Picked rows show a check mark. With nothing picked, items go to the library root (“No folder” clears the pick). The pick is saved with the preferences, and picked folders missing from the open library are ignored. A search box narrows the list by any part of a folder’s path.
- Recent: after each collect the destination folders move to the front of a list of the last 20. When there are recent or picked folders, and the library has more folders than that, the list opens with a Recent block (picked folders first, then up to five recent ones, each shown with its full path), a line, and then every folder in tree order. While searching, the Recent block is filtered too, and folders in it are left out of the list below. Right-click a row in the block and choose Remove from Recent to drop it.
- New folder: when the search text is not empty and no folder is named exactly that, the last row reads New Folder “text”. Clicking it, or pressing Enter when it is the only row, makes a top-level folder with that name, picks it and clears the search. Creating it this way is not an undo step.
- Name, description and tags: the fields above the list apply to what is collected next. Name is used only when exactly one file is collected (the file’s extension stays). Description becomes the item’s annotation. In the tag field, Enter or a comma adds a tag; each tag chip has a × to remove it; while typing, up to eight suggestions from the library appear (starred tags first, then recently used, then the rest) and a click adds one. Pasting uses the same fields. The three fields are cleared after each collect; the folder pick stays.
Drag the header to move it; ×, Esc or ⌘⇧K close it (Esc first leaves a field being typed in). Its position is remembered between launches. The code is in crates/falcon-app/src/collect.rs; the name goes through ImportOptions::name.
It is a second OS window (an egui immediate viewport with with_always_on_top and with_decorations(false)). In eframe 0.37 each viewport has its own winit window and input, and WindowEvent::DroppedFile lands in that viewport’s raw.dropped_files, so drops work on it directly; no fallback was needed. On a backend without multiple viewports egui draws it as a panel inside the main window instead, and drops there go to the main window’s import.
What Eagle’s collect window is, from collect-window/ and js/background.js (seen):
- It is not a drop target. It opens for one incoming item, when the browser extension adds an image (
general.showCollectModal, “ask me where to save”, off by default, also switchable through the API) or after a screenshot (general.showCaptureCollectModal). - Left side (240 pt): the image preview with its dimensions, a 1–5 rating, an editable title and description, and tags. Right side: a searchable folder list with recent folders first, multi-select and creating a folder; choosing a folder saves the item there with the folder’s auto-tags and closes the window.
- The window is frameless with 8 pt rounded corners on a translucent background. Its size, position and always-on-top setting are set in Eagle’s main process, which is not in the readable bundle (inferred: small, centred, on top).
falcon keeps the folder chooser and the always-on-top window and also works as a drop target. It has no browser extension, so its one incoming item is a screenshot, as in Eagle’s showCaptureCollectModal case. Not built: the rating, a preview of the item, and dropping images straight from a browser (macOS hands winit only file drops, so browser image drags that carry no file are ignored).
With Preferences ▸ Screenshot ▸ “Choose where each screenshot is saved” on, an area or window screenshot does not import at once. The window opens (and comes to the front) with the drop zone replaced by a Screenshot block that names the file and has Save and Discard buttons. Name holds “Screenshot - date time” and Tags holds “Screenshot” (unless tagging is off); both can be edited. Save adds the screenshot to the folders picked in the list, or the library root with none, with the name and tags as they stand, then moves those folders to the front of Recent, as a collect does. Discard drops it, and closing the window drops everything waiting. More screenshots taken meanwhile queue behind the first, and the block reads “Screenshot (and N more)”; each Save or Discard brings the next one’s name and tags into the fields. While one waits, drops and pastes are refused with “Save or discard the screenshot first”. Save is off when no library is open. A screen screenshot never waits here, as in Eagle. The waiting state is collect::Pending; the file stays in falcon’s temp folder until the import copies it in or Discard deletes it.
Screen capture
Eagle captures to the library from a global key or its tray icon. falcon does the same on macOS through the system’s screencapture, with the three modes of Eagle.
What Eagle does (seen in its shipped source, the keys and defaults under Preferences):
- On macOS an area is captured with
screencapture -x -i -o -t pngand a window with-x -w -o -t png, into a temporary PNG. The full screen is taken from the display under the pointer through Electron’s desktop capturer. - The picture is converted to the chosen format and quality. When @2x is off on a scale-2 screen the picture is halved. The item is named “Screenshot - YYYY-MM-DD HH.mm.ss”, gets the tag “Screenshot” when it has no tags and auto-tagging is on, and is copied to the clipboard when that is on.
- A capture request is ignored while one is running, and it needs an open library.
- An area or window screenshot opens the collect window when
showCaptureCollectModalis on. Otherwise Eagle shows its own pop-up (when the notification master switch and the screenshot switch are on) and imports. A full-screen screenshot never opens the collect window and shows no pop-up; it plays a sound when sound effects are on. The sound of an area or window screenshot comes with its pop-up.
How falcon does it (crates/falcon-app/src/capture.rs, app/system.rs):
-
A capture is started by a global key, a menu bar icon item or the same command from elsewhere. With no library open it says “Open a library to take screenshots into”. A request while one is running is ignored. A locked falcon takes no screenshots.
-
The pointer’s screen decides the scale (
backingScaleFactor) and the screen number used by the full-screen mode. Area and window run/usr/sbin/screencapturewith Eagle’s arguments; the full screen runsscreencapture -x -D n -t png, silent like the others, since falcon plays its own capture sound (see Sound effects in the preferences section). A cancelled pick (Esc) leaves no file, and falcon then does nothing. -
The PNG is written into
falcon-pastein the system temp folder, the same folder pasted images use. It is converted withfalcon_library::export::convert_bytesto the chosen format and quality (a PNG at full size is only renamed), halved on its longer side when “Keep full Retina resolution (@2x)” is off and the screen has a scale of 1.5 or more, and named “Screenshot - YYYY-MM-DD HH.mm.ss”. Two shots in one second get “ 2“, “ 3“ and so on in the file name. -
With “Copy screenshots to the clipboard too” on, the worker thread puts the picture on the clipboard as image data; a failure goes to the error log.
-
The screenshot then goes into the library root with the tag “Screenshot” (unless tagging is off) as an ordinary background import, with no folders; see Screenshots in the import chapter. With “Choose where each screenshot is saved” on it waits in the collect window instead, for an area or window shot only.
-
A failure shows “The screenshot failed: …” as an error note and is written to the error log.
-
The “Screenshot added” pop-up, or the notification while falcon is hidden, shows for an area or window screenshot that goes straight in, when its switches are on. A batch that held only screenshots, imported straight in or saved from the collect window, does not also post “Import finished”.
-
The capture sound plays as the screenshot goes into the library, under the rules in Sound effects.
Not built: Eagle’s plugin and extension capture paths.
Menu bar icon
Eagle shows a tray icon on macOS (general.showMenuItem, on by default) with Show Eagle, Capture Area, Capture Window, Capture Full Screen and Quit Eagle. falcon shows a menu bar icon the same way while Preferences ▸ General ▸ “Show the falcon icon in the menu bar” is on, which it is by default. Turning it off removes the icon at once.
- The icon is the SF Symbol “bird” as a template image, which follows the menu bar’s light or dark colours. If the symbol is unavailable the button shows the title “Falcon”.
- The menu holds Show Falcon, a separator, Screenshot of an Area, Screenshot of a Window, Screenshot of the Screen, a separator and Quit Falcon. Each of the first four shows its global key beside it; a capture item shows no key while the screenshot keys are off, and a command with no key shows none.
- Show Falcon brings the main window to the front, restoring it if it is minimized or hidden. The screenshot items start a capture. Quit Falcon quits as the app menu’s Quit does.
- The menu is rebuilt only when the keys it shows change. Locked, only Show Falcon acts.
- The code is
crates/falcon-app/src/tray.rs; the choices are queued and acted on in the next frame, as the menu bar’s are.
Global shortcuts
Eagle has five global keys (global.show.eagle, global.show.search, global.capture.area, global.capture.window, global.capture.full; only the area key has a default, CmdOrCtrl + Alt + E). falcon has the same five as the Global group at the top of Preferences ▸ Shortcuts, with the same defaults: Show falcon, Show falcon and focus the search, Screenshot of an area (⌘⌥E), Screenshot of a window and Screenshot of the screen. The three capture rows are shown again in Preferences ▸ Screenshot.
- They work from any app. falcon registers them with Carbon’s
RegisterEventHotKey, which is still the only system API for app-wide hot keys and needs no accessibility permission. They are not read from egui’s input, so they never fire window commands. - The capture keys are registered only while “Use the screenshot keys” is on (
screencapture.shortcutsEnable). It is off by default, where Eagle has it on: Eagle is often running too and holds ⌥⌘E. The area key still defaults to ⌥⌘E, so turning the switch on gives Eagle’s layout. Show falcon and Show falcon and focus the search are registered only once bound; neither has a default. - Show falcon brings the window to the front. Show falcon and focus the search also closes the viewer and puts the cursor in the search box, when a library is open. While falcon is locked only the Show keys act, and they show the lock screen.
- While Preferences is open the global keys follow the settings being edited, before Save: turning “Use the screenshot keys” on or off, or recording a new chord, registers the keys again at once, and Cancel brings back the saved ones. While a row waits for a key press all global keys are let go, so the recorder can take chords the system would otherwise swallow.
- A chord another app already holds is refused by the system, and the other chords still work. The key’s row, in the Screenshot pane and in the Shortcuts pane’s Global group, then says so in orange, for example “⌥⌘E is taken by another app”, until the switch or the chord changes and registering succeeds (falcon tries again on each change). Outside Preferences (at launch, or after Save) an error note says the same, for example “⌘⌥E can’t be used for “Screenshot of an area”: another app has it”. Only keys with a key code on the system’s ANSI layout can be registered; a chord without one is left out.
- The recorder’s conflict check counts the Global group with the ordinary commands, as in Eagle: a global chord cannot repeat another command’s chord. The in-app key handler skips the Global group, so a global chord never fires a window command.
- The code is
crates/falcon-app/src/hotkeys.rs(registration and key codes) andapp/system.rs(acting on presses).
System notifications
Eagle’s pop-ups (“Import Successful”, “Export Successful”) are its own windows, not system notifications. falcon posts macOS notifications instead (crates/falcon-app/src/notify.rs), which macOS shows only while falcon is in the background.
A screenshot gets falcon’s own pop-up instead (capture_notice.rs, ui/capture_notice.rs), whichever app is in front; only while falcon is hidden, when it can’t put up a window, does it get the macOS notification, so exactly one of the two shows, and neither when the screenshot switch is off. As in Eagle’s pop-up, it is a window of its own (an egui viewport: borderless, transparent, always on top) at the top right of the usable part of the screen under the pointer, on every Space and over full-screen apps. It holds a 300 pt card with the screenshot’s 44 pt thumbnail, “Screenshot added” and the hint “Click to show, drag to dismiss”. The card slides in from the screen edge, stays 1.5 seconds (while the pointer is on it, it stays), has a close button while hovered, and a new screenshot replaces it. Clicking it opens All with the item selected. Dragged to the right it follows the pointer; let go past 40% of its width (or what is left to the screen edge, if less), or flicked at 600 pt/s, it slides out and is gone, and otherwise it springs back. A drag that sets off vertically does nothing, and a press that moves less than 4 pt is a click. The thumbnail is decoded off the UI thread from a hard link to the shot file, and the card shows without a picture if that fails.
The window is shown without activating falcon. It can’t become the key or main window (winit’s window class has its answers replaced for this window) and asks not to activate falcon when clicked through AppKit’s private _setPreventsActivation:, where the system has it. It is not an NSPanel, which is AppKit’s public way: winit can’t make one, and changing a live window’s class breaks the key-value observers on it, which raises an exception when it closes. The window’s transparent margins (for the shadow) still catch clicks while it is up.
| Title | Text | When |
|---|---|---|
| Screenshot added | The item’s name | An area or window screenshot went straight into the library while falcon was hidden; “Show a notification for each screenshot” |
| Import finished | “N items added to the library” | A background import added items; “Say how many items an import added”. Not posted for a batch that held only screenshots (also those saved from the collect window) |
| Export finished | The in-window note, for example “Exported 3 items” | An export finished; “Show a notification when an export finishes” |
- Each follows its switch in Preferences ▸ Notification under the master switch “Show notices”. All are on by default.
- They use the UserNotifications framework through
msg_send, because the framework crate in the local cache binds an older objc2 than the app. The framework needs a bundle identifier, so they work only in the bundled app made byscripts/bundle.sh. Run unbundled (for examplecargo run) they are dropped without a message. - The first one asks macOS for permission (alert and sound). Later ones use the answer given.
- macOS shows them only while falcon is not the active app, so they never double the notes shown in the window.
- Clicking one only brings falcon forward. It does not select or preview the item, as Eagle’s pop-up does not either.
Organising
This covers folder icons, colours, sorts and passwords, drag reordering in the sidebar, nested smart folders, tag groups and saved filters. The code is split three ways:
crates/falcon-app/src/organize.rsholds the decisions, with unit tests: drop zones, tree moves, which folders are hidden, sort keys, tag group sections and password form checks.folder_icons.rsholds the icon table.ui/organize.rsholds the egui side.ui/sidebar/folders.rsholds several picked folders: the ⌘ and ⇧ clicks (rules inlogic::TreeSel), their menu, and the batch rename, Move Folder, New Folder with Selection and delete dialogs. The batch edits are infalcon_library::editand the rename templates infalcon_library::rename.
In an open folder that has a parent, hovering the toolbar title shows the folder’s full path, and right-clicking it lists the folder (ticked) and its ancestors up to the top-level folder; picking one opens it like a sidebar click. The list comes from logic::ancestor_chain. A top-level folder, a smart folder and several picked folders get no menu.
The toolbar title follows Eagle’s breadcrumbs (index.html .breadcrumbs, seen): the page, folder or smart folder name, or “N folders selected”, stays while searching; when a search or any filter narrows the view (not on the All Tags page, whose search filters tags), a separator and “Search results (N)” follow, N being the items shown, with thousands separators. Clicking the first part then clears the search and the filters, as Eagle’s does. Like Eagle, the title of a nested folder is its own name, with the path on hover, not a clickable path.
Library writes go through falcon_library (see Library format and Smart folders).
What Eagle does, from app.bundle.js, index.html, js/directives/*.html and style/components/*.scss:
| Question | Eagle | Seen or inferred | falcon |
|---|---|---|---|
| Folder menu | New Folder, New Subfolder, Move Folder, Quick Access, Rename, Copy as Link, Auto-Tag, a Password submenu (Create, Edit, Remove, Lock Now), a Sort By submenu that sorts folders by title (see below), expand and collapse entries, Duplicate, Export, Show Subfolder Content, Change Icon (submenu), a colour row, Remove. Subfolder, auto-tag, duplicate and export are disabled while the folder is locked | Seen | New Folder (a sibling right below, ⇧⌘N), New Subfolder (disabled while locked), Move Folder…, Quick Access, Move to Top Level, Rename, Auto-Tags… (disabled while locked), a Password submenu, a Sort By submenu (folders by title), a Sort Items By submenu, Change Icon, a colour row, Duplicate and an Export submenu (both disabled while locked), a checked Show Subfolder Content after Export (the View menu’s switch), Delete Folder (disabled while locked), and three expand entries (see below). No Copy as Link yet |
| Several folders selected | ⌘-click adds the open folder and toggles the clicked one (the last one can’t be taken away); the row added becomes the current row. ⇧-click adds every visible row from the current row (else the open folder) to the clicked one, and does nothing when there is none. The open folder stays the one opened by a plain click. Picked rows are highlighted; the content shows the union of their items in the global sort and the title reads “N folders selected”. Smart folders work the same way, and picking in one tree clears the other. Opening any view and the arrow keys in the sidebar clear the picks. Right-clicking a picked folder opens a menu for all of them: Quick Access add or remove (by the clicked row’s state), Move Folder, Rename N Items, an Order By submenu (Global Settings, ten keys, Ascending and Descending, checked when all agree), Export ▸ To Computer, an emoji icon submenu, the colour row and Remove. The smart folder version has Order By, Quick Access, Rename, Change Icon with colours and Remove | Seen in clickNode, multipleOpenFolder, openFolderContextMenu | Same clicks, highlight, union, title and clearing (logic::TreeSel, ui/sidebar/folders.rs; deleting folders clears them too), except that a later ⌘-click doesn’t put the open folder back after it was taken out, and a ⌘-click when no folder is open simply opens the clicked one. The sidebar arrow keys don’t exist in falcon. The menus have the same entries in the same order, with Sort Items By, Move Folders…, Rename N Folders… and Delete N Folders as labels; the icon submenu is falcon’s icon grid (falcon has no emoji icons). Export to Computer asks for one place and writes each folder there as its own directory, asking Replace or Keep Both when any is already there. Each command is one undo step |
| Batch rename | A dialog with two modes. Format: a name template with Insert menus for an index (%N to %NNNN, padded; up to nine N), dates (%D, %DD, %DDD, %DHM, %DHMS for today, the same with M for the modification date and B for the creation date, which are the folder’s modificationTime), %T (the folder’s tags) and * (the old name), and a start number. Each token is replaced at its first place only. Replace Text: find and replace, where /pattern/flags is a regular expression. Both offer a text case (none, upper, lower, capitalise words) and a preview; from ten folders on it asks first. An empty template result keeps the old name. Folder names aren’t sanitised | Seen in batchRenameModal and renameFolders | Same modes, tokens and rules (falcon_library::rename, unit tested), a live before → after preview and the confirmation from ten on. %F is accepted and is empty for folders, as in Eagle. A find and replace that empties a name keeps the old one (Eagle would save an empty name). Regex replacements use Rust’s $1 syntax. There is no find history. Library::rename_folders saves once |
| Move Folder dialog | Move Folder in the folder menu (one folder) or the multi-folder menu, or Edit ▸ Folder ▸ Move Folder, opens a searchable folder tree. The folders being moved are greyed out and their subfolders hidden. Each row moves them above, into or below it, after a confirmation naming the first folder and how many others. Several folders keep their visual order; a folder whose parent moves too stays inside it; moving into a moved folder’s descendant does nothing | Seen in move-folder.js, moveFoldersAsSibling, moveFoldersToFolder | Same: a search box (substring, keeping matching folders’ ancestors), the tree with carets (starting as collapsed as the sidebar), Above, Into and Below buttons on the hovered row and the confirmation. Library::move_folders makes the move in one save; folders inside a locked folder aren’t offered. Not done: creating a folder from the search text |
| Deleting folders | Remove Folder asks only when the folder has items or subfolders: “delete the folder?” with a checkbox, ticked by default, to move the items to the Trash too. With several picked, it always asks, naming the count, and skips locked folders. Items still in another folder stay out of the Trash either way. A locked folder can’t be removed | Seen in removeFolder, removeSelectedFolders | Same rules and default (App::ask_delete_folders, Library::delete_folders): unticking keeps every item and only removes the folder ids from them. A locked folder’s Delete Folder is disabled. If the open folder goes, All opens (Eagle opens a sibling). Deleting several smart folders asks first |
| Duplicate | Duplicate copies a folder and its subfolders right after it with the same names, new ids, a new modificationTime, no password and no items; icon, colour, description, auto-tags, cover and sort are kept. A smart folder’s Duplicate deep-copies it, rules and children included, just before the original | Seen in cloneFolder, cloneSmartFolder | Same (Library::duplicate_folder, Library::duplicate_smart_folder). falcon sets the copied smart folders’ parent to their new parent’s id at every level |
| New Folder with Selection | With several items selected, the item menu offers New Folder with N Items: a dialog asks for the name, which is cleaned (white space squeezed, : / \ turned into spaces), and a folder is added at the end of the top level with the items in it, then opened and made the last used folder (⇧D adds to it next). It is not in the menu bar | Seen in newFolderWidthSelection | Same (logic::clean_folder_name); the Create button waits for a name |
| Sorting folders by title | The folder menu’s Sort By submenu has four entries, in this order: Title (Current Level) (A→Z), Title (Current Level) (Z→A), Title (All) (A→Z), Title (All) (Z→A). Current Level sorts the list the clicked folder sits in (its siblings, or the top level), not its subfolders. All sorts the top level and then every folder’s children. Each asks first (“sort the folder list by Title?”, with a Sort Folders By Title button), then saves once. The Folders heading has no menu | Seen | The same four entries and meaning, worded as “Title, This Level (A→Z)” and so on (organize::FOLDER_TITLE_SORTS, title_sort_parent), with a confirmation. Library::sort_subfolders does the sort with a natural, case-insensitive title order; the change is one undo step. falcon also puts the Sort By submenu on the Folders heading’s context menu, where This Level means the top level |
| Item sort menu | Not in the single-folder menu. It is in the multi-folder menu and in the toolbar’s layout panel. Entries: Global Settings (no own sort), Date Added, Date Modified, Date Created, Title, Extension, File Size, Dimensions, Rating, Duration, Random, then Ascending and Descending. The layout panel also lists Manual for folders | Seen | Same entries, offered as Sort Items By in the folder and smart folder menus, the menus for several folders (where a new key keeps each folder’s direction, as Eagle’s does) and the toolbar’s layout menu. Tags is offered too, since falcon’s sort supports it |
| Toolbar sort in a folder | While a folder or smart folder is open, the toolbar shows that folder’s sort. Picking a key writes orderBy to the folder; the global sort changes only outside folders. Global Settings deletes orderBy and sortIncrease. The direction is written to the folder only when it has an orderBy; otherwise it changes the global direction | Seen | Same |
| Random and Manual | Random shuffles a cached order. A refresh button, a filter change and a folder reload clear the cache. Manual sorts by each item’s order[folderId], falling back to the import time. Dragging items within a folder switches it to Manual | Seen. That every visit reshuffles is inferred | Random shuffles once per visit to the folder. Duration sorts by whole seconds, shortest first, with items that have none counting as 0, the same order as the main sort menu, which lists Duration and Random like a folder’s own sort. Manual sorts by the folder’s order map with the import-time fallback (search::sort_manual), in all four layouts. Manual exists only for folders: smart folders and the other views don’t offer it. Random, duration and manual apply only when no search or colour filter is ranking the list |
| Reordering items by hand | Items can be dragged to a new place only inside a folder whose sort is Manual or Date Added (its own, or the global one when it has none), with no filter and no search (the box-sortable class on body). Each card then gets a 16 pt drop strip just outside its left and right edges, as tall as the card; the strip under the pointer shows a 2 pt primary border, a 30% primary fill and 3 pt corners. Selected and pinned cards get no strips. The left strip drops before the card, the right one after it. In the list layout the strips are 4 pt bars across the top and bottom of each row. Dropping writes new order values and switches the folder to Manual, ascending | Seen | Same rule (organize::manual_sortable). logic::manual_drop hit-tests every layout: a card’s box (with its caption) widened by 16 pt on each side, left half before, right half after, the nearer card winning in a gap and the later one on a tie; list rows split at half height. The dragged cards take no drop. The strip is drawn in the gap between cards (as wide as the gap, up to 16 pt) rather than overlapping the next card. Dropping calls Library::move_items_manually, which switches the folder to Manual; a drop that leaves the order unchanged writes nothing. Dragging items onto a sidebar folder moves or adds them (see the Dropping items on folders row below). Making the hit area cover the card halves, not only the strips, is falcon’s choice |
| Icon picker | A Change Icon submenu holding a wrapping grid of 24 pt icons. The first, blank cell is the default icon | Seen | Same layout. Each of Eagle’s 161 names gets the closest Phosphor Light glyph (folder_icons.rs), and number10 to number20 are drawn as digits. Eagle’s artwork is not copied |
| Colours | A separate row in the menu: a “none” circle (which deletes iconColor), then red, orange, yellow, green, aqua, blue, purple and pink, 16 pt wide with 8 pt gaps. The colour tints the sidebar icon (not the label) and the folder’s guide lines at 25%. Dark-theme values: #FF6667, #FF9F0A, #FFD60A, #30D159, #40CBE0, #00AAFF, #C499FF, #FF99CC; “none” is #BDBEC0 | Seen | Same row and tints (theme::ICON_*). New subfolders take their parent’s icon and colour (done in the library) |
| Reordering by drag | While a folder is dragged, each row shows three drop strips. A 9 pt strip at the top inserts before the row and shows a 1 pt primary-colour line. A 10 pt strip across the name nests the folder and shows a primary-tinted box with a 1 pt border and 6 pt corners. A 9 pt strip at the bottom inserts after. Dropping a folder into itself or a descendant is refused | Seen | The top and bottom quarters of a row show an insertion line and reorder; the middle half shows the box and nests (organize::drop_zone). organize::tree_drop works out the move and Library::reorder_folder makes it. Dropping on the Folders heading still moves a folder to the end of the top level. ⌘] and ⌘[ move the open folder up or down among its siblings, and ⇧⌘] and ⇧⌘[ to the top or bottom, as Eagle’s Edit ▸ Move Folder does; the same keys work on an open smart folder (organize::arrange) |
| Dropping items on folders | Inside a folder, dropping items on another folder moves them: they leave the open folder and join the target, and holding ⌥ adds them instead. From any other view a drop adds. A single item that is already only in the target is left alone. Trashed items are restored. The target’s auto-tags are added to the items. A notice gives the count and links to the folder. A locked target asks for its password first | Seen in classifySelected | Same rule (logic::folder_drop_picked decides, logic::apply_folder_drop makes the change for each item, App::drop_items_on_folder in ui/drop_unlock.rs runs it for sidebar rows, Quick Access rows and subfolder cards). When the open view is a folder, a drop moves the items out of it and into the target, and holding ⌥ adds them, keeping their other folders. With several folders picked in the sidebar (⌘- or ⇧-clicks), a drop without ⌥ takes the items out of every picked folder, as Eagle does. From every other view (All, Uncategorized, a tag, a smart folder, Recently Used, the Trash and so on) a drop adds. Dropping on the folder that is already open does nothing. Trashed items dropped on a folder come out of the Trash. A notice reads “Moved 3 items to Logos” or “Added 1 item to Logos”, and the whole drop is one undo step. The target’s auto-tags (its own and its parents’, see Folder auto-tags) are appended to every dropped item that lacks them, in the same step. A locked target that isn’t unlocked yet asks for its password first (with its hint); the right password unlocks it for the session and the drop goes ahead, Cancel drops nothing. The folder’s name in the notice is a link that opens it |
| Quick Access drag and drop | The heading counts the pinned entries and a click folds the list away, which Eagle remembers. Rows drag: released over another pinned row’s top or bottom half, a row moves above or below it. A folder or smart folder dragged from its tree is pinned at that spot, or moved if already pinned. Items dropped on a pinned folder do what dropping on it in the tree does | Seen | Same: the heading reads “Quick Access (3)”, and whether the list is folded is kept in falcon’s saved state (quick_collapsed). The Smart Folders and Folders headings fold the same way (smart_collapsed, folders_collapsed), show a caret after the title and a count only when it is above zero. The sidebar filter also filters smart folders and hides a heading with nothing matching; Quick Access is never filtered. The insertion line is the tree’s, and the halves split the row at its middle (App::quick_drop, Library::place_quick_access, one undo step). Item drops use the same rule as the tree (App::drop_items_on_folder), with ⌥ to add, several picked folders emptied and a locked folder’s password asked first; pinned smart folders take none |
| Copy Link | Not recorded | Not recorded | The folder menu (after Rename) copies http://localhost:<port>/folder?id=<id> and a smart folder’s menu (not a group’s) copies http://localhost:<port>/smart-folder?id=<id>, with a “Link copied” notice; the port is chosen as for items (the running API’s, else the first one in Preferences) and the API answers the link with a redirect. A Copy submenu right below holds Eagle Link, the eagle://folder/<id> or eagle://smart-folder/<id> form. The links are made by links::api_folder_link, links::api_smart_link, links::folder_link and links::smart_link, and the eagle:// ones open that folder when clicked. |
| Expanding and collapsing | The folder and smart folder menus have three entries: the folder, which flips it and sets its direct children to match; the same level, which sets every sibling to the state the clicked one is not in; and all, which is the / key. Right-clicking a folder’s arrow shows only these. Clicking the arrow with ⌥⌘ does all, ⌘ the level and ⌥ the folder and its children | Seen | Same, in both trees (organize::toggle_with_children, toggle_level, toggle_all; App::expand_menu). Each entry opens everything if the clicked folder is collapsed, else closes. Folders without children are never marked collapsed. A plain arrow click still flips just that folder |
| Folder settings and auto-tags | The folder menu’s Set Auto-Tag (⇧⌘R, also Edit ▸ Folder ▸ Set Current Folder) opens a dialog with the folder name and a tag input labelled for auto-tagging, plus Save Changes and Cancel; ⌘↩ in the name saves, Esc cancels. Saving writes the name and the folder’s tags. When the tag list changed, the folder’s new tags are appended to every item in the folder or any folder below it (trashed items too), without repeats; removed tags stay on items. There is no inherit or apply-to-subfolders switch: the parents’ tags always apply (see Folder auto-tags), and there is no preference | Seen in AutoTaggingController and index.html | Auto-Tags… in the folder menu, or ⇧⌘R on the open folder, opens Folder Settings: the name, the tags as removable chips and a field that adds tags on Return or comma, a note naming the tags inherited from the folders above, and Save and Cancel (⌘↩ saves, Esc cancels). Save goes through Library::rename_folder and Library::set_folder_auto_tags, which changes only the folder’s tags key (any cached extendTags is left as it was) and appends the new list to items in the folder and below with Eagle’s rule; a notice gives the count. Name and items are one undo step. The subfolder cards’ menu has no entry yet |
| Lock icon | Folders with a password show a lock on their row: closed while locked, open once unlocked | Seen | Same, beside the count |
| Unlock screen | The content area shows an illustration, “Unlock to view Contents”, “Hint: …” when a hint is set, and a password field. Enter checks the password, and a wrong one shakes the field. Touch ID and the licence code also unlock | Seen | Same without the illustration or licence code. A wrong password turns the field red. When this Mac can use Touch ID, a Use Touch ID button under the field asks for a fingerprint (whatever the app lock’s Touch ID setting is) and, if it matches, unlocks the folder like the right password does; a failed or cancelled prompt changes nothing. The answer unlocks only the folder it was asked for, even if another is open by then |
| What a lock hides | Items in a locked folder, or in any folder below one, are left out of All, search and every other list. A locked folder’s subfolders are hidden in the sidebar until it is unlocked | Seen | Same (organize::hidden_folders, without_hidden). An item filed in both a locked folder and another folder is hidden everywhere. Sidebar counts for All, Uncategorized and smart folders still include locked items. A locked folder’s own count is hidden |
| Uncategorized | Lists live items with no folder, and also items whose only folder id names no folder (a folder deleted on another device, say). An item left with two such ids is not listed | Seen in contentFilter | Same (logic::uncategorized), for the list and the sidebar count |
| Recently Used | A list of item ids per library, newest first, kept in the app (not the library), up to 5,000. An item counts once the viewer has shown it for a second, or when it is opened in its default app, with Open With, in a new window, or copied with ⌘C; a batch of 20 or more is not recorded. The page lists those items in that order whatever the sort, trashed ones included. Its menu clears the list | Seen in RecentFileManager and contentFilter | Same (recent.rs), saved with falcon’s state by library path. The order is taken when the page opens, so items viewed from it move up the next time it is opened |
| How long an unlock lasts | Unlocking sets a runtime flag that is never saved, so a folder stays unlocked until Lock Now, a library reload or quitting. Leaving the folder doesn’t re-lock it | Seen. That the flag isn’t saved is inferred from the save code | Same: falcon keeps a set of unlocked ids for the session and clears it when a library opens. Creating or changing a password leaves the folder unlocked until Lock Now |
| Password dialog | New: Password, Enter again, Password Hints. Change: Old Password, New Password, Enter again, Password Hints. Remove: the current password (or the licence code) | Seen | Same fields (organize::check_password_form). Passwords with characters beyond Latin-1 are refused, because Eagle’s btoa can’t store them |
| Tag groups | The All Tags page has its own sidebar: All, Uncategorized, Starred, then “Groups (n)” with a + button and a drag-sortable list of groups. Each group row has a tag icon tinted with the group colour. The tag list is grouped A–Z by default; “Group by > Tag Group” shows a heading “Name (count)” per group with its tags under it, and an “Uncategorized” pseudo-group last. Each tag shows a dot in its group’s colour. The + button creates “Untitled” and starts a rename. Dragging tags onto a group moves them there (with Alt, copies them). The group menu has Find items, Rename, Remove, Add to Library and a colour row. Removing a group keeps its tags, and asks first only if the group has tags. The main sidebar has no tag group section | Seen | The page has a Group by switch (Alphabet or Tag Group) and a New Group button. In Tag Group mode, each group gets a heading with its colour dot and count, followed by an “Ungrouped” section; when every tag is in a group, that section says so in place of an empty list (Eagle’s “done” state for its Uncategorized view). Drag a tag onto a heading to move it into that group, or onto Ungrouped to take it out; the tag menu’s Move to Group does the same, and its Add to Group files the tags in another group as well. Double-click a heading, or use its menu, to rename it. The menu also finds the items with any of the group’s tags (the All page with the tag filter set to them, Any), recolours and deletes the group (keeping its tags, and confirming first when it has tags). Not yet built: the group sidebar and Alt-copy |
| Reordering tag groups | Groups are reordered by dragging them in the page’s group sidebar (a sortable list), which rewrites the order of tagsGroups | Seen | No group sidebar, so the Tag Group headings themselves drag: the top half of another heading drops before it, the bottom half after, shown by a primary insertion line (organize::group_drop, Library::reorder_tag_group). The heading menu also has Move Up and Move Down, for when dragging past a long group is awkward. Each move is one undo step |
| Tag group descriptions | With one group selected in the group sidebar, an editable line sits under its heading: 13 pt secondary text with a placeholder, no line breaks, a hover tint, and a bottom border. It saves when the field loses focus, only if the text changed (typing also saves after a second’s pause) | Seen | A description shows under its heading in muted 13 pt text whenever it is set. Add Description… or Edit Description… in the heading menu, or a double-click on the text, opens a field in its place; leaving the field saves a changed text (line breaks become spaces, organize::clean_description), Escape cancels. There is no save-while-typing. The field is separate from the inline rename, since falcon has no rename popover |
| Saved filters | A Saved Filter button at the right of the filter bar opens a menu. “Save this filter” asks for a name; it is disabled when there is no filter and no search. The saved filters are listed below it, and clicking one applies it. Each has a more button with Rename and Remove (no confirmation). The menu can be sorted by drag and searched. Applying a saved filter replaces the whole filter and runs its saved search | Seen | A Saved menu in the filter bar holds Save This Filter… and the list. Clicking an entry applies it, replacing the filter and the search. Each entry’s ⋯ menu has Rename, Move Up, Move Down and Remove. Saving keeps every rule falcon’s filter has, including dates, duration, camera and font status, and applying restores them and refills the Date range and Duration fields. Saved image and semantic searches go through AI Search (see that chapter); comments, the 4:3, 3:4, 16:9, 9:16 and custom ratios, and excluded Uncategorized are kept |
| Nested smart folders | The smart folder tree has carets, guide lines and the same three drop strips (nest or reorder). Smart folders take an icon and colour from the same picker and colour row. The dialog has no parent choice: the New Subfolder entry in a smart folder’s menu passes the parent. The + menu also offers a smart folder group | Seen | The same tree, drag rules and icon and colour menu, with New Subfolder… in the menu. falcon’s dialog also has an Inside choice (the parent, with each entry’s icon so groups stand out), an icon and a colour, so it can move an existing smart folder too. The menu’s Rename edits the name in place in the sidebar (Enter or leaving the field saves, Escape cancels), and Edit Rules… opens the dialog |
| Smart folder groups | The + beside Smart Folders opens a menu: New Smart Folder and New Smart Folder Group. The group is created at the top level as “Untitled” with the grid icon and no conditions, opened, and put into rename. It shows the union of its children and no count badge. Its menu is the same as a smart folder’s (Edit Rules included) | Seen | Same menu, creation, icon (a group whose icon is cleared still falls back to the grid, organize::smart_icon), union and missing badge, also in Quick Access. The group’s menu has New Subfolder…, Rename, Quick Access, Move to Top Level, Sort Items By, Change Icon, the colour row and Delete Smart Folder Group; Edit Rules… is left out, since a group has no rules and the dialog would turn it into a smart folder. A new child of a group starts with the default smart folder icon rather than inheriting the grid |
Tags
Tag picker
The + after the inspector’s tags, Organize ▸ Add Tags… (⌘T) and T open the tag picker for the selected items (or the item in the viewer), after Eagle’s tag select panel. It opens 320 points wide (400 with its sidebar) and its right edge, bottom edge and corner resize it, within Eagle’s limits; the size is remembered. The code is in crates/falcon-app/src/ui/inspector/tag_picker.rs; what it lists, ranks and recommends is in crates/falcon-app/src/tags.rs, with unit tests.
| Topic | Eagle 4.0 | Evidence | falcon |
|---|---|---|---|
| Panel | A 320 × 480 pt floating panel, opened at the pointer and moved left of the inspector if it would cover it. Header: search field, a sidebar toggle (only with tag groups), settings and pin. It can be dragged and resized; clicking elsewhere closes it unless pinned | Seen in inspector-tag-select-panel.* | Same size and placement, search field, sidebar toggle, settings, pin and a close button, with a footer of shortcut hints as in Eagle. It can be resized, and dragged by the grip over the search icon, as in Eagle |
| Sections | Without a search: Starred, Recent (the latest 12 history tags still in use, in history order), Recommended, one section per tag group in the library’s order, then the rest. Each tag shows once, in the first that applies; a tag in several groups goes under its first. A header click folds a section | Seen in tag-select-panel.js | Same sections and order. Folding lasts for the session (Eagle remembers it) |
| Cells | Columns of 140 pt (100, 120 or 160 in settings), or one per row; a check on tags every picked item has, the group’s colour, the name and the count; a background on tags the items have | Seen | Same |
| Settings | Show starred, recent and recommended tags, show counts, column size, list mode | Seen (local storage) | Same choices, saved with falcon’s preferences (Prefs::tag_picker) |
| Sidebar | All, each group, Uncategorized, with counts; Tab and ⇧Tab move through them. A tag created while a group is chosen joins it | Seen | Same (Uncategorized is called Ungrouped, as on the All Tags page) |
| Search | Letters in order anywhere in the name match (mchn finds machine), ignoring spaces in the text, case and accents; from two letters a group’s name also finds its tags; Pinyin too. Results: a Create cell (unless a tag has exactly that name), then the exact name, names containing the text (earlier first), then the rest by edit distance | Seen in filterByKeyword, sortByKeywordSimilarity | Same, without Pinyin. Eagle’s tie rule for close edit distances is not a strict order; falcon breaks ties by putting tags in use before merely recommended ones |
| Keys | ↑ ↓ ← → move the highlight across the grid and between sections (← → also move the text cursor), Return adds the highlighted tag or creates the typed ones, ⌘Return keeps the text, Esc closes. Typing highlights the first tag that starts with the text, else the first cell | Seen in select-panel.js | Same |
| Adding and removing | Clicking a tag the items lack adds it to all of them; clicking a checked tag takes it off all of them. The text is cleared after a click unless ⌘ is held. Typed text is split at commas (including , and 、), semicolons and line breaks. Pasting several tags adds them all, in reverse order, up to 200 | Seen | Same. Each change is one undo step |
| Recommended | Words from the one item’s name and from its folders’ names (with their parents), at least two characters, with at most one digit, capitalised, without stop words or tags the items share; existing tags first. Eagle segments Chinese and Japanese with jieba. Nothing for more than 500 items | Seen in getSuggestTags | Same rules with a short English stop-word list and plain word splitting (no jieba) |
| History | Each added tag moves to the front of historyTags (120 kept), one after another, so the last one added leads. A tag taken off its last item leaves the history | Seen in addTags, addHistoryTag, removeTag | Same (Library::add_tags_to_items, Library::remove_tags_from_items) |
All Tags page
| Topic | Eagle 4.0 | Evidence | falcon |
|---|---|---|---|
| Sidebar | A strip at the left: All, Uncategorized (tags in no group), Starred, each with its count, then a Groups heading with its count and a + that makes a group, opens it and starts its name; then each group with its colour and tag count. A click opens that list (another list drops the picked tags and the search text). Double-click a group to rename it; drag groups to reorder them; right-click for the group menu. Tags dragged onto a group move into it (leaving their other groups), or with ⌥ are filed there too; dropped on Starred they are starred. A drag carries every picked tag when the dragged one is picked, else just it. A group takes the drop only when it lacks one of the tags | Seen in tag-manager.html, onDropTag, onDropStarredTag, addTagsToGroup, openPrevGroup | Same (tag_view.rs, ui/grid/tag_sidebar.rs). All shows every tag, A-Z or by group as before; Uncategorized shows the tags in no group in A-Z sections, with an “All Filed” note when every tag is in a group; Starred shows the starred tags (starred tags no item has count 0); a group shows its heading, description and tags, sorted by the page’s sort. The sidebar is 190 px wide and can’t be resized |
| Layout | The list fills the space right of the tag sidebar, with 16 px side padding and no sideways scrolling; rows of tags are fitted to that width. The sort and grouping controls are not in the list | Seen in tag-manager.html, _tag-manager.scss | The list keeps a 17 px margin (GRID_PAD_X) on both sides for every row of chips, including wrapped ones (tag_flow). A chip too long for a row has its name cut short. falcon keeps its toolbar above the list: its controls wrap to further rows, the filter box narrows, and on a row too narrow for a button the button’s text is cut short, so nothing sits under the inspector. On a narrow page the tag sidebar gives way down to 100 px to leave the list 150 px, and below that it folds away until the page is wider |
| Picking tags | A click picks one tag, ⌘ adds or drops one, ⇧ adds the run from the last clicked tag in page order (keeping the others); a rubber band picks too. Double-click shows the tag’s items | Seen in selectTag, tag-manager.html | Same clicks (tags::select_click) and double-click, and the same rubber band: a plain press on empty space drops the picks, and dragging from there picks every chip the band touches, live, replacing the picks (tag_view::banded); near the top or bottom edge the list scrolls. A press with ⌘ or ⇧ on empty space does nothing, as in Eagle. Right-clicking a tag outside the picked ones picks just it. Esc drops the picks |
| Tag menu | For the picked tags: Filter with Tags, Star / Unstar, Rename (one tag), Merge N Tags (shown from two, enabled up to five), Add to Group ▸, Move to Group ▸, Remove from Group, Remove | Seen | Show Items (one tag opens its page; several open All with the tag filter set to any of them), Star or Unstar (Unstar when all are starred; several are starred with the first one first, as Eagle’s addStarredTags), Rename…, Merge N Tags…, Add to Group ▸ (a group that already has them all is checked and disabled), Move to Group ▸ (with New Group…), Remove from Group (from every group holding them), Delete. Each is one undo step |
| Merge | A dialog with the first tag’s name filled in; Merge renames every picked tag to the typed name on items, in tag groups, the history and folder auto-tags, and in smart folder rules. An empty name does nothing | Seen in the merge handler | Library::merge_tags, the same rename for each tag as falcon’s rename: items, history, starred tags, groups, folder auto-tags (the first old name becomes the new one, duplicates dropped) and the value lists of smart folder tags rules (changed in place, so the entry’s other keys keep their order). One undo step |
| Lists on rename | In the history and in each group, if the new name is already listed the old one is dropped; otherwise the new name takes its place | Seen | Same, for the history, starred tags and groups. Folder auto-tags and smart folder tag rules take the new name in place of the first old one, then drop duplicates. Deleting a tag also takes it off folder auto-tags (not off smart folder rules), as Eagle’s delete does |
| Sort and grouping | A > Z, Z > A, by count high to low or low to high, and Group by Alphabet or Tag Group, remembered app-wide in localStorage (TAGS_SORT_BY, TAGS_SORT_INCREASE, TAGS_GROUP_BY) | Seen in context.tagList, changeSort, changeGroupBy | Same four sorts and the same two groupings, kept in falcon’s saved state (tag_look) for every library and across launches. Sections stay A-Z (Z-A for Z > A, # last), sorted inside; in tag group mode the sort orders the ungrouped tags and groups keep their order |
| Clear tag history | A confirmation, then historyTags is emptied | Seen in clearHistoryTags | Clear Tag History… on the page, with a confirmation; one undo step |
Copying tags
| Topic | Eagle 4.0 | Evidence | falcon |
|---|---|---|---|
| Where | Organize ▸ Copy Tags (⇧⌘C), Paste Tags (⇧⌘V), Clear Tags (no key); the item menu’s Copy Tags and Paste Tags; the inspector tag area’s menu (Copy, Paste, Clear) and a tag chip’s menu (Filter, Edit, Copy that tag, Paste) | Seen in app.bundle.js menus | Same places. A chip’s menu has Show Items with This Tag, Copy Tag and Paste Tags (renaming stays on the All Tags page) |
| Copy | Keeps the tags in an in-app clipboard and also writes them to the system clipboard, one per line, with a short “copied” notice. Several items copy the tags they all share | Seen in copyTags, calculateTags | Same; the clipboard is shared by the open libraries and lasts until falcon quits. Copying nothing keeps the old clipboard and says so |
| Paste | Appends each copied tag an item lacks, in order; disabled until something was copied. No notice and no history change | Seen in pasteTags | Same, with a notice (“Pasted 3 tags to 2 items”); one undo step |
| Clear | Empties the selection’s tags, with a notice that has an Undo button | Seen in clearAllTags | Same, with a notice; ⌘Z undoes it. The history is left alone in both apps |
Comments on a region
In the viewer, comment mode (the speech-bubble button or C) lets you drag a rectangle over the image. When you let go, a small box opens for the text: Save (or ⌘↩) adds the comment, Esc or Cancel drops it. Regions of 10 image pixels or less on either side are ignored, as in Eagle. Each comment is drawn as a numbered outline; hovering one shows its text. In comment mode, clicking an outline edits its text and its × button deletes it. Dragging inside an outline moves it, and dragging its right edge, bottom edge or bottom-right corner (the three small squares) resizes it; the box stays inside the image and at least 10 image pixels on each side, and the new box is saved when you let go, as one undo step. Outside comment mode, a drag pans the image as usual. Grid cards show the number of comments in a blue disc at the top right. The code is in ui/viewer/comments.rs.
What Eagle does, from the viewer’s comment-item and comments-container directives, js/annotation-preview.js and the card template:
| Question | Eagle | Seen or inferred | falcon |
|---|---|---|---|
| Mode | A toolbar button with a count badge, shortcut C, hidden for video, audio, fonts, 3D models, web links, PDF and text | Seen | Same button, badge and key, hidden for the same kinds |
| New region | Drag a box; on release it is scaled to the original’s pixels, rounded, kept only when both sides are over 10 px, and added at once with empty text; an editable box then opens on it | Seen | Same rule. The box is added when the text is saved, and is clipped to the image |
| Markers | Outlined boxes with a number badge and a × remove button; hovering shows the text in a floating box | Seen | Same |
| Moving and resizing | Dragging a box in comment mode moves it; jQuery UI’s resizable() with its defaults (handles e,s,se, minWidth/minHeight 10 layout px) resizes it. The pointer offset is divided by the viewer’s zoom; a resize is scaled by the image-to-layout ratio, so x, y, width and height can be fractional. Saved on mouse-up (image-change); a press without movement writes nothing. Moves are not clamped to the image | Seen in the commentItem directive and the bundled jQuery UI defaults | Same three handles and move. The box is clamped to the image (one sticking out from Eagle is pulled in on the next drag) and kept at least 10 image pixels a side. Values are written to two decimals, whole ones as integers. Saved on release through update_comment, one undo step per drag. Items without dimensions get no regions. The math (adjust_region, drag_region, region_handle, with_region) is unit-tested |
| Text | Stored as HTML from a contenteditable box (<br> for line breaks) | Seen | Stored as plain text. Eagle’s HTML is shown with the tags removed (<br> and block ends become line breaks, common entities decoded). Eagle will show falcon’s line breaks collapsed and would read a < in falcon’s text as HTML |
| Card badge | has-annotation card class: a 20 pt primary-blue disc with a white border at the top right, showing the count. Its title was meant to list the texts with tags stripped, but replaceAll is given the pattern as a string, so it shows the raw HTML | Seen | Same disc, without the title |
| Inspector | Comments listed with their texts; clicking one opens comment mode and scrolls to it | Seen | Listed with edit and delete buttons; clicking a region comment opens the viewer in comment mode |
| Undo | Deleting a comment offers a short-lived undo notice | Seen | Part of the normal undo history |
Rotate, flip and crop
The viewer bar has Rotate Left and Rotate Right and a Flip button for jpg, png, webp and bmp, and Crop for jpg, png and webp. Flip mirrors horizontally, or vertically with ⇧ held while clicking, and ⇧F (rebindable) flips horizontally, as Eagle’s single flip button and edit.image.flip shortcut (Shift + F) do (seen in preview-window.js and default-preferences.js). Eagle’s button also mirrors a playing video; falcon’s appears for images only. The card menu offers Flip Horizontal and Flip Vertical as two entries, for every selected item in those formats. By default all of these rewrite the original (see Crop and rotate). The crop tool starts on the whole image. Drag its edges or corners, or drag inside it to move it; while you drag, x:… y:…, W:… H:… in pixels shows underneath. The bar has width and height fields showing the rectangle’s size; type a number and press Enter (or leave the field) to resize it from its top-left corner, kept inside the image (Enter in the width field moves to the height field, Esc reverts, and while a field has focus the crop keys don’t act). Save asks for confirmation, then overwrites the original. Save As crops into a new item instead (Library::crop_item_as_new), leaves the original alone, needs no confirmation, closes the viewer and selects the new item. ⇧C opens the tool and, while it is open, saves. These edits can’t be undone, so they clear the undo history.
Preferences ▸ Preview ▸ Image also has The viewer opens images in Preview (the default) or Comment mode. This is a falcon addition: Eagle declares habits.defaultMode (“preview” or “annotation”) but never reads it, and falcon uses that key and those values in the preferences it reports (Prefs::viewer_mode). With Comment mode, the viewer turns comment mode on once per viewer session, for the first item that takes region comments (supports_regions), unless presenting or the crop tool is open (logic::comment_mode_on_open). Comment mode otherwise carries over between items, so stepping on, to a video and back, or turning it off with C, the button or Esc does not turn it on again until the viewer is closed and reopened. Video, audio, PDF and the other items without regions never get it. With Preview nothing changes, including the comment list’s own click that turns it on.
Preferences ▸ Preview ▸ Image has Eagle’s “Rotate and flip images” choice (imageRotateMode): Writes to the file (the default) or Changes the preview only. In preview-only mode the viewer’s rotate and flip buttons, ⇧R, ⇧F and the Edit ▸ Edit Image commands, and the item menu opened by right-clicking the picture, act on the image shown in the viewer by turning and mirroring how it is drawn (fit-to-window uses the turned size) and write nothing. The turn is dropped when the viewer moves to another item or closes, and when the crop tool opens (its rectangle is in file pixels). Region comments are hidden while the picture is turned. The card menu in the grid, and items other than the one in the viewer, always write to the file. The turn and mirror math (ViewTransform) is in ui/viewer/crop.rs. The rectangle math is in ui/viewer/crop.rs.
Combine Images
Combine Images (⇧⌘M, edit.image.merge, rebindable) is in Edit ▸ Edit Image and in the item menu when two or more of the selected items are images falcon can decode: PNG, JPEG, WebP, GIF, BMP and TIFF. Other selected items are left out. In Eagle this is the combine-images plugin; falcon’s dialog is its own design.
The dialog sets the layout (Horizontal, Vertical or Grid with a number of columns), the gap in pixels and the background (a colour or transparent), and remembers them until falcon quits. Pictures go in the order they have on screen, not the order they were clicked. A preview and the result’s size update as the settings change; they come from the items’ saved sizes and the thumbnails already loaded, so a picture with no thumbnail in memory shows as a plain box, and a JPEG turned by its EXIF orientation may be shown with its sides swapped until the real combine reads its header.
A row scales every picture to the height of the tallest, and a column to the width of the widest, so smaller pictures are scaled up. A grid’s cell is the largest width by the largest height, and each picture is fitted inside and centred; the last row may be short. Past 16,384 px a side or 100 megapixels everything, gaps included, is scaled down together, the dialog and the toast say so, and a selection that can’t fit even at 1 px a picture is refused.
The work runs on a thread behind the progress modal with Cancel, one picture in memory at a time. Pictures are decoded to sRGB and turned by their EXIF orientation; an animation gives its first frame, and the result has 8 bits per channel. The result is a new PNG item named Combined Image, in the open folder (every picked folder when several are shown together, with their auto-tags) or in no folder elsewhere, and it is selected. The sources aren’t changed, and it takes no tags, notes or link from them. One undo step moves the new item to the Trash. A picture that can’t be read stops the combine with its name in the error toast and adds nothing.
The layout maths and the compositing are in falcon_library::combine, the dialog in ui/dialogs/combine.rs.
Open in other apps
The card menu has Open in Default App (⇧↩), Open With and Show in Finder (⌘↩); the inspector has an Open in Default App button above Show in Finder; every viewer’s bar (image, audio, video, font, and anything else that calls App::viewer_open_button) has an open icon at the right with a caret that lists Open With. Files are handed to /usr/bin/open on a short-lived thread (open_with.rs): it needs no permission, never reads the file (so a dataless iCloud original is fetched by the app that opens it), and the UI never waits. The Open With list comes from NSWorkspace URLsForApplicationsToOpenURL: (macOS 12 and later; earlier systems get only the default from URLForApplicationToOpenURL:), asked about an empty stand-in file with the same extension in a temp folder, because LaunchServices answers nothing for a missing path, and cached per extension.
| Eagle | Seen or inferred | falcon | |
|---|---|---|---|
| Menu entries | Card menu and View menu: Open in Default App, Open in Other App, Reveal in Finder, plus Open in New Window and Open URL | Seen (app.bundle.js) | Open in Default App, Open With, Show in Finder in the card menu; no separate window or URL entry here |
| Keys | view.file.opendefault Shift + Enter, view.file.openfinder CmdOrCtrl + Enter, view.file.openother unbound, view.file.opennewwindow CmdOrCtrl + O | Seen (default-preferences.js) | ⇧↩ and ⌘↩, rebindable; no Open With key |
| Several items | Opens up to 40 selected files, each in its default app | Seen (openFilesWithDefault) | The same cap; one open call for all |
| Open With list | A submenu on macOS, for one item only: the default first, labelled “(Default)” and followed by a separator, then the others by name; Eagle itself left out. Built by a helper (launchHandler) run on an empty temp file with the item’s extension, cached per extension | Seen (get-associated-application) | The same order, label and separator, falcon’s own bundle left out, one entry per bundle path and per name |
| Opening in an app | open -a APP FILE, with a 10 s timeout | Seen (openWithApplicationPath) | open -a APP -- FILE, not waited on |
| Default app | Sent to the main process (open-with-default) | Seen (the message); inferred (Electron’s shell.openPath) | open -- FILE… |
iCloud Drive
Libraries kept in iCloud Drive with Optimise Mac Storage on have most originals and thumbnails evicted: ls -lO shows the flags compressed,dataless (seen on a real library), stat still reports the full size, and the first read blocks until iCloud has downloaded the file. icloud.rs tells these apart from stat alone: the SF_DATALESS bit (0x40000000) of st_flags, or a missing file with iCloud’s older .NAME.icloud placeholder beside it.
Opening such an item in the viewer shows “Downloading from iCloud…” over the dimmed preview, with a moving bar, while a background thread asks iCloud for the file (NSFileManager startDownloadingUbiquitousItemAtURL:error:) and checks every 250 ms until the flag clears; then the item opens as usual (image, GIF, audio, video, font or PDF). An error from NSURLUbiquitousItemDownloadingErrorKey, or no answer within 20 minutes, shows the reason and a Try Again button. Files the ubiquity API refuses (another file provider) get a background read of their first byte instead. Moving to another item or closing the viewer stops the polling but never cancels or evicts anything at the system level. Progress is not shown as a percentage: NSURL has no progress key, and NSMetadataQuery’s would need a query per file (inferred to be not worth it). Local files take the same path as before with one extra stat.
Where originals are read:
| Where | Thread | With a dataless file |
|---|---|---|
Grid, preview and full-size decodes, QuickLook renders (loader.rs) | Loader workers | A worker waits for the download; the UI does not |
Viewer: GIF, WebP and APNG frames, Video::open, audio decoding, font specimen, PDF | UI | Held behind the download screen until local |
| Animated grid cards and hover previews | UI | Not played: the card keeps its thumbnail (one stat when playback would start) |
| Waveforms, duplicate scan, background imports | Background threads | Wait in their own thread |
| HTTP API | Server thread | /api/item/thumbnail returns a path and reads nothing. The add-from-path routes import while holding the library lock, so adding a dataless file through the API still holds the window until it has downloaded (not yet handled) |
| Refresh Thumbnail of several items | Background thread | Items still in iCloud are skipped as below; a progress modal shows the count, with Cancel between items |
| Rotate, flip, crop, Refresh Thumbnail (one item), Refresh Palette | UI | Items still in iCloud are skipped, their download starts, and the status line says to try again |
| Drag out | UI | The drag image falls back to a plain square instead of reading a dataless thumbnail; the files are passed by path |
| ⌘C, Open in Default App, Open With, Show in Finder | UI | Paths only, never read |
| Export to Computer, Export as .eaglepack, Import Eagle Package, Empty Trash | Background thread | The copy waits in its own thread |
| Copy ▸ Base64, Copy ▸ Thumbnail (one item) | UI | Refused with a message while the file is in iCloud |
How long downloads take depends on the file and the link (inferred: seconds for a thumbnail, minutes for a several-hundred-megabyte book).
When the original of a picture is gone from disk (and is not an iCloud file waiting to download), the viewer says so under its placeholder, with the same words as the card’s tooltip for a missing file: the file wasn’t found, and a synced library may still be syncing (icloud::is_missing, logic::broken_thumb_hint).
Folder covers
Eagle shows covers only on the subfolder cards at the top of a folder’s content (sub-folder-list in index.html), not in the sidebar. From calculateImageBinding and setFolderCover (observed):
- The cover is the folder’s
coverIditem, set with “Set as Folder Cover” (⌥⇧C) on a card while browsing a folder. - Without one, it is the folder’s first item in the current sort order (Eagle’s default is newest import first). Trashed items, items with
noPreview, text files and items in locked folders are skipped. - A folder with neither takes the cover of its first subfolder that has one.
- Eagle writes no cover file. A crop clears the cached covers of the item’s folders so they redraw.
falcon draws the subfolder cards in the Waterfall, Justified and Grid layouts with the same rule (ui/grid/covers.rs). Locked folders show a folder icon instead of a cover. In the List layout Eagle turns the cards into full-width rows (_list-layout.scss, seen): a square cover a third of the zoom tall (at least 30 pt), the name, the item and subfolder counts and the folder’s date at the right, with alternate rows shaded. falcon draws the same rows, with the same heading, fold, rename by double-click, drop target and right-click menu as the cards; the rows scroll on their own once they would take more than 40% of the grid’s height (Eagle scrolls them with the items).
Export and copy-out
Export to Computer (⌘E, or Export ▸ Export to Computer… in the item menu) copies the selected items’ originals into a folder chosen in a dialog. Each file keeps its name, <name>.<ext>, and its modification time. Names that clash within the export, ignoring case, are numbered as Eagle numbers them: two pic.png become pic-1.png and pic-2.png, padded to the widest number (pic-01.png … pic-10.png).
When files of the same names are already in the folder, falcon asks first, saying how many. Replace overwrites them. Keep Both leaves them and numbers the new copies, with the file already there counting as the first (pic.png exists, so the copy is pic-2.png). Eagle’s prompt offers only Overwrite or Cancel, and its item export then numbers the copies anyway; its folder export overwrites.
The copy runs on a background thread behind the shared progress modal (“Export to Computer”, “3 of 10”, the file being written, and Cancel), as Eagle shows its export progress. Cancel stops it before the next file and keeps the files already written, saying “Stopped after exporting 3 of 10 items”; one export or other long operation runs at a time. Each file is written under a temporary name beside its destination and renamed into place, so a failed copy never leaves half a file. When it ends, falcon says “Exported N items” (with any failures) and, as Eagle does, shows the files in Finder, or the folder when there are more than 500.
A folder, a subfolder or a smart folder exports the same way from its menu (Export ▸ Export to Computer…) or from the inspector’s Export button while it is open. A folder becomes a directory tree under the chosen folder: one directory per folder and subfolder, holding the originals of the items filed directly in it, so an item in two folders is copied into both. Directory names are cleaned up as Eagle does on the Mac (tabs and runs of spaces become one space, :, / and \ become spaces, a trailing dot goes, and an empty name becomes “Untitled”). Two sibling folders whose names clean up alike share a directory, with clashing files numbered. Items in a locked folder that hasn’t been unlocked are left out, and the menu entry is disabled on a locked folder, as in Eagle. A smart folder becomes one flat directory of its items, without its subfolders, as in Eagle. When the folder’s directory already exists, falcon asks the same Replace / Keep Both question; both export into the existing directory. When the copy ends, Finder shows the new directory.
Export as .eaglepack (⇧⌘E, or the Export submenu of the item, folder and smart folder menus and of the inspector’s Export button) asks where to save, suggesting Untitled.eaglepack for items and <folder name>.eaglepack for a folder, then writes the pack on a background thread behind a progress modal (“3 of 10”, with Cancel) and shows it in Finder. Cancel removes the unfinished file, so nothing is left at the chosen path. Items are packed with their folders cleared, a folder with its subfolders, and a smart folder as one plain folder of its items; an empty smart folder exports nothing, as in Eagle. The format is described in Export (.eaglepack).
Export in Format (⇧E, or the item menu’s Export ▸ Export in Format…) converts the selected images. A dialog picks the format (JPG, PNG or WebP), the quality for JPG and WebP (1 to 100, default 90; WebP at 100 is lossless) and the longest side: the original size, 4096, 2048, 1920, 1280, 1024 or 512 px, or a custom number. Images are only scaled down (Lanczos), never up; JPG puts transparent parts on white. Then a folder is chosen, and the files are named <name>.<new extension>, with the same clash numbering and Replace / Keep Both prompt as Export to Computer. Raster images falcon decodes (PNG, JPEG, WebP, GIF’s first frame, BMP, TIFF, ICO) and SVG (drawn at its natural size) can be converted; other items are skipped, and the dialog says how many. The conversion runs on the background thread behind the same progress modal (titled Export in Format), with Cancel. Eagle’s own dialog for this comes from its separate Custom Export plugin, which is not in the app’s source, so falcon’s options are its own.
Export to CSV (the item menu’s Export submenu, the inspector’s Export button for the items shown in the open folder or smart folder, or a key that is unbound by default, as in Eagle, which then takes the selection or else every item shown) saves one row of metadata per item. The columns follow Eagle’s order: id, name, extension, width, height, duration, source URL, notes, comments (one per line), tags and folder names (each joined with “, “), size in bytes, rating, import and modification times as YYYY-MM-DD HH:MM:SS in UTC, and the original’s path. Zero or missing numbers are left empty, as in Eagle. The header text is falcon’s own. A field is quoted, with quotes doubled, only when it holds a comma, a quote or a line break; lines end in \n and the file is UTF-8 with a byte order mark, as Eagle writes it. Finder then shows the file. The writer is export::csv in the library, a few lines with no CSV crate.
The item menu’s Copy, Copy File Path and Copy submenu entries (also in the image viewer’s right-click menu, which is the item menu) put files, text or images on the clipboard, for every selected item as Eagle does:
| Entry | Key | What is copied |
|---|---|---|
| Copy | ⌘C | The originals, as files (App::copy_items). One jpg, jpeg, png or bmp also goes as its pixels, as Eagle does: clip_image::copy puts the image data (public.png or public.jpeg, plus public.tiff; a bmp as TIFF only), the original’s public.file-url and NSFilenamesPboardType on the general pasteboard at once, so an image editor pastes pixels and Finder pastes the file. Several items, other formats and originals still in iCloud (copying the pixels reads the file) go as files only, and so does a single picture when the pasteboard refuses it |
| Copy File Path | ⌥⌘C | Each original’s path, one per line |
| Copy ▸ Eagle Link | unbound | Each item’s local API link, http://localhost:<port>/item?id=<id>, one per line, as Eagle’s Copy Eagle Link (edit.copy.eaglelink) writes it with its own port. The port is the running API’s, else the first one set in Preferences; the API answers the link with a redirect to the item’s eagle:// link, which opens the item in falcon (see HTTP API) |
| Copy ▸ Source URL | unbound | Each item’s source URL, one per line (falcon’s own entry); items without one are skipped, and a message says when none has one |
| Copy ▸ Folder Path | ⌥⇧⌘C | Each item’s <id>.info folder with a trailing slash, one per line, as Eagle copies it |
| Copy ▸ Thumbnail | unbound | One item: its thumbnail as an image (the original for an image with no thumbnail). Several: their thumbnail files as files, as Eagle copies them |
| Copy ▸ Base64 | unbound | One item only (disabled for several, as in Eagle): a data:image/…;base64, URL of the original for JPEG, PNG, WebP and GIF, otherwise of the thumbnail, whose real format (usually WebP) names the type. Eagle labels every thumbnail webp |
| Copy ▸ Name | unbound | Each item’s name, one per line |
Base64 and a single thumbnail read the file on the window’s thread, so an item still in iCloud is refused with a message instead of holding the window. The unbound copies can be given keys under Preferences → Shortcuts (Eagle’s edit.copy.* ids, edit.copy.eaglelink for Eagle Link; Source URL has falcon’s own, edit.copy.sourceurl).
Where files go and how they are named is worked out in falcon_library::export (a Plan, with tests); ui/dialogs/export.rs asks, runs and reports. Exports write only inside the folder picked in the dialog.
Eagle MCP
A Model Context Protocol server, so AI assistants on the same Mac can use the open library. It is built in (falcon_server::mcp, run by app/mcp.rs) and off by default. Preferences ▸ Eagle MCP turns it on and shows whether it is running, at what address, and a client configuration to copy.
It listens on http://127.0.0.1:41610/mcp. The first port can be changed, and it tries the next four if a port is taken; ranges that include Eagle’s 41595 or 8000 are refused. It runs only while a library is open and always serves the shown library, following a switch to another one. It has no way to open a library itself.
Protocol. Streamable HTTP, with every message a POST and every answer one JSON object. Revision 2026-07-28 is served per request, with its _meta fields, its Mcp-Method and Mcp-Name header checks and server/discover. Clients of 2025-11-25, 2025-06-18 and 2025-03-26 use initialize as usual; no session is made. GET and DELETE answer 405. There is no change notification, so an assistant sees a change to the write tools when it next lists them, usually on reconnecting.
Who can connect. A request must be addressed to the server’s own Host, and any Origin must be on the loopback address; others get 403. No CORS headers are sent and the body must be application/json, so a web page can’t read an answer or post a form. Without a token, any program on the Mac can connect. “Require the API token” asks for the Developer pane’s token as Authorization: Bearer, and makes one if there is none.
| Tool | Does | Needs writes on |
|---|---|---|
get_library_info | Name, path, and item, Trash, folder and tag counts | No |
list_folders | The folder tree with descriptions, tags and item counts | No |
list_tags | Tags in use with item counts, filtered and paged | No |
search_items | Search or list items by text (the search box’s syntax), tags, folder, extension and rating, sorted and paged | No |
get_item | One item in full, with its note, comments, palette and file path | No |
get_item_thumbnail | The thumbnail as an image, at most 1024 px; a JPEG, or a PNG if any of it is transparent. A small SVG, which keeps no thumbnail file, is drawn from the original | No |
add_item_from_path | Copies a file into the library, with a name, tags, folders, note, URL and rating | Yes |
update_item | Changes an item’s name, tags, note, URL or rating | Yes |
create_folder | Creates a folder at the top level or inside another | Yes |
move_items_to_trash | Moves items to the Trash | Yes |
Writes. “Allow assistants to change the library” is off by default. While it is off the write tools aren’t listed, and calling one is refused with a message saying why. Each write takes the library lock and makes the same Library call the app makes, so it is batched and flushed with the frame’s edits, and it joins the library’s undo history as one step: ⌘Z reverses it, and an undone import goes to the Trash. No tool deletes anything for good.
Library text is data. Results are JSON documents, so names, notes and tags stay values and are never spliced into a sentence. The server’s instructions and the tool descriptions tell the assistant not to follow instructions found in them.
Folders with a password. There is no tool to unlock one, even if it is unlocked in the window. It appears in list_folders with only its id, name and locked: true. Its subfolders and items, including items also in other folders, behave as if they didn’t exist for every tool, and they aren’t counted. Passwords and hints are never sent.
Running it
cargo run -p falcon-app -- path/to/Some.library [ITEM…|FILE…]
scripts/bundle.sh # builds target/release/Falcon.app
One item id (or an eagle://item/<id> link) opens that item in the viewer. Several ids select them. Files are imported.
Actions
An action is a named list of organising steps (add or remove tags, add to or remove from folders, set a rating, auto-tag or write a description with AI) with an optional shortcut. Running it applies every step to the selected items. Each library has its own actions. In Eagle 4.0.0 this is a plugin (ai-action); falcon builds it in and keeps the actions in its own app data.
What Eagle does
These findings come from Eagle 4.0.0’s application bundle. The file and the byte offset where each was read are given in brackets.
- Eagle’s own window does not run actions. A small class,
eagle.action(js/lib/api/ai-action.js, also bundled intoapp.bundle.jsat byte 58723), loads the list, shows it in the menu bar and hands each run to a plugin with the idai-action, Eagle’s AI Action plugin. When that plugin isn’t installed, Eagle offers to install it. The plugin isn’t part of the application bundle, so its editor, its step types and how it applies them can’t be read there. - The list is stored in the library, in
actions.config.jsonat the library root (initActions(rootDir),ai-action.js; called when a library opens,app.bundle.jsbyte 1184710). It is a JSON array. A missing file, or one that doesn’t parse, is an empty list. Eagle polls the file every 4 seconds (fs.watchFile, interval 4000) and rebuilds the menu when its modification time changes. Eagle’s window never writes the file. - Eagle’s window reads three fields of each entry:
id,nameandshortcut. The shortcut is an Electron accelerator. On Windows Eagle replacesCommandwithCmdOrCtrl(formatShortcut), so the stored text usesCommand, as Eagle’s keybind preferences do ("CmdOrCtrl + Shift + N"style, with spaces around+,js/default-preferences.js). - The menu bar has a top-level Actions menu between View and Window. It is shown only while
eagle.action.actions.length > 0. Each entry is an action’s name with its shortcut as the menu accelerator. A click (or the shortcut) first asks “Confirm bulk action” when 30 or more items are selected (checkOperationSafety2(count, …, 30)). It then opens the plugin with{ triggerType: 'shortcut', actionId }(app.bundle.jsbytes 1319438 to 1319966). - The toolbar has an Actions button (tooltip “Actions” with the key G). It sits right of the plugin buttons and left of the layout button, and is hidden on the All Tags page (
index.htmlline 630,ng-hide="viewMode=='alltags'"). It and the bare key G ('g': $scope.openActionsPanel,app.bundle.jsbyte 2344422) open the plugin’s panel with the selected items’ ids (eagle.action.open, byte 2070795). - The item context menu has no Actions entry. Searching the bundle finds only the two references above to
openActionsPaneland one toeagle.action.open. - While the plugin’s window is open, switching libraries is refused with “AI Action in Progress” (
app.bundle.jsbyte 1812698). - Eagle’s translations still carry strings from its earlier built-in Actions (
i18n/en.js): an Actions panel with Create Action, Edit, Duplicate and Remove, an empty-state introduction and “No Action Found” (actionsPanel.*), an editor with Name, Steps, Add Step and Shortcuts (modal.newAction.*), seven rule types (createAction.rule.*: Add tags, Remove tags, Clear tag, Add folders, Remove folders, Clear Folder, Rating) and a “Task completed” notice (notify.actions.run). No code in 4.0.0 uses them. A library made by an earlier Eagle can hold anactions.jsonfile holding[], which 4.0.0 doesn’t read.
What falcon does
Falcon runs actions itself, with Eagle’s earlier rule types as its steps and two AI steps of its own (see AI steps). It keeps them in falcon’s own app data, not in the library, and never reads, creates, changes or deletes the library’s actions.config.json, which belongs to Eagle’s AI Action plugin. Actions made in Eagle’s plugin don’t appear in falcon, and falcon’s don’t appear in Eagle.
| Entry point | Behaviour |
|---|---|
| Actions menu | Between View and Window, only while falcon has actions for the open library. Each item shows the action’s shortcut. It is enabled when items are selected (or one is in the viewer), outside the Trash, and when falcon can run the action |
| Toolbar button | The lightning button left of the layout button opens the Actions panel under it. Hidden on the All Tags page, as in Eagle |
| G | Opens the Actions panel (organize.actions.panel, rebindable under Preferences → Shortcuts) |
| An action’s shortcut | Runs it on the selection when no text field or dialog has the keys. The modifiers must match exactly, as with Eagle’s menu accelerators |
The Actions panel lists the library’s actions with a search box (every typed word must be in the name). Clicking an action runs it on the selection and closes the panel. Each row’s ⋯ menu has Edit…, Duplicate (a copy named “… Copy” right after it, without the shortcut) and Remove… (asks first). New Action opens the editor. The foot of the panel says how many items are selected. A click outside the panel or Esc closes it.
The editor has a name, a shortcut button and the ordered steps.
- The shortcut button records the next key press under Eagle’s recorder rules (see Shortcut settings in The desktop app). Letters and digits need a modifier, Esc stops recording and Backspace clears. A chord that another action or a falcon command already uses is refused, and “Used by …” names that action or command.
- Each step has a kind and its parameters. Add tags and Remove tags take tags typed into a field (separated by commas, Return adds them), with the library’s matching tags offered below the field. Add to folders and Remove from folders take folders picked from the folder tree, with a search. Set rating uses the inspector’s five stars. Clear all tags, Remove from all folders and Auto-tag with AI have no parameters. Write a description with AI has Replace existing notes. The arrows move a step up or down, and × removes it.
- Save stays disabled until the action has a name, at least one step, and tags or folders for every step that needs them.
Preferences → Shortcuts lists the open library’s actions and their keys in an Actions group. The keys are set in the editor. A command can’t be given a key an action uses.
Running an action
- The steps run in order. Each step is applied to every selected item before the next step starts. “Clear all tags, then add ref” leaves only ref; the reverse leaves no tags.
- Items in the Trash are skipped, and actions don’t run on the Trash page. Adding an item to a folder therefore never takes it out of the Trash.
- From 30 selected items the run asks first, as Eagle’s Actions menu does.
- The whole run is one undo step, named “Run name”. The notice after it says how many items changed and has an Undo button.
- Adding tags puts them at the front of the tag history, as the tag picker does. Adding to a folder also adds that folder’s auto-tags. A folder that no longer exists is skipped, and the notice names how many were skipped. A failed write stops the run at that point. The steps already done stay in the same undo step.
AI steps
Auto-tag with AI adds the tags the default model suggests. Write a description with AI puts the model’s description in the item’s notes; an item that already has notes keeps them and isn’t sent, unless Replace existing notes is on. Both use the shared interface and the default models from Preferences ▸ AI Models: the vision model for an item with a picture, otherwise the chat model, or the vision model when no chat model is set.
- Before the run. With no provider added or no default vision model, the run doesn’t start and a message says which to set. From 30 selected items the confirmation also names the provider and host that receive the items.
- What is sent. One request per step per item: the item’s name, file type and notes as JSON, the instructions, and its picture. The picture is its thumbnail, or its original when it has no thumbnail and is an image, as a JPEG at most 1024 pixels on a side. Tags, the source URL, folder names, file paths and other items are not sent. An item with no picture goes as text only.
- In the background. The window stays usable during a run. A small panel in the bottom right corner, over any page and the viewer, shows the action’s name, a bar, “3 of 10” with the time left, the item being asked and Cancel; it also shows a run going in another library, and names that library. A spinner beside the library name and the Dock icon show the progress too, and the run counts as a running task for the switch, close and quit warnings. Items are asked one at a time, and the library lock is not held during a request. Cancel stops the request at once. The panel is hidden while presenting.
- One at a time. While a run goes in any open library, or has not reported yet, starting another is refused with a message naming the run and its library. Nothing is queued. Actions with only rule steps, and long operations, aren’t held up, and each is its own undo step.
- Results are checked, then applied together. Nothing is written while the model is asked. A reply must be JSON fitting the step’s schema, with no other fields. Tags must be text, at most 32 of them and 64 characters each; a description at most 4000 characters, neither with control characters. When the run ends, or Cancel stops it, every step of the action is applied to the items that came through, in order, as one undo step named “Run name”.
- Items that change during a run. An item is read again just before it is asked, so the request carries its name and notes as they are then. The steps are applied to each item as it is at the end, and change only the fields they set: a rename, a new rating or a new tag made during the run stays, and auto-tag adds to the tags as they are. Write a description keeps notes written during the run and lists the item among those that kept their notes; with Replace existing notes on, it replaces them. Text being typed in the inspector stays in the field and is saved when the field loses focus. Undo goes back to the item as it was when the run applied. An item moved to the Trash or deleted during the run is skipped, gets none of the steps, and is listed with the reason; if that happens before its turn, it isn’t sent.
- Failures. An item fails on invalid JSON, a wrong shape, an empty reply, a timeout, a refused request, an unreachable provider or a picture that can’t be read. A failed item gets none of the action’s steps, including its rule steps, and the run goes on to the next item. A reply cut off at the provider’s length limit counts as a failure too.
- The report. A run where everything went well ends in the usual notice with Undo. Otherwise an alert says how many items changed, which model ran, each failed item with its reason, each skipped item with its reason, which items kept their notes, and how many Cancel stopped before. Finished items stay applied after Cancel, and ⌘Z reverses them all. An alert that comes while another is up waits for it to close. A notification goes out too, under Preferences ▸ Notification’s main switch; macOS shows it only while falcon isn’t the active app.
- Model output is data. A reply can only become tags or notes (
StepOutput). The model is offered no tools (on Anthropic, one output tool whose input is the reply), nothing in a reply is run, and the instructions tell the model that the item’s text is data, not instructions.
Switching away from the library asks first, as with other tasks, and the run keeps going against its own library. Its report then names the library, in the shown one, with no Undo button in the notice: the undo step is in that library’s history, for ⌘Z once it is shown. Closing the library asks first, stops the run, applies and writes the finished items and then closes; the report still appears, without Undo, as the undo step goes with the library. Quitting asks first and stops every run the same way; the report is dropped with the app. If the API’s library/switch leaves another library in the slot, the run waits up to five seconds for its own to come back and never touches the other, even a copy with the same item ids; if its own doesn’t return, the items are reported as failed.
Entries falcon can’t run
Falcon keeps every entry exactly as it read it. An entry that has fields other than id, name, shortcut and steps, or steps falcon doesn’t know (written by a newer falcon, or edited by hand), is listed greyed in the panel and the menu with the note “This action has steps Falcon can’t run”. Its shortcut is still reserved, and it can be removed. It can’t be run, edited or duplicated. Saving another action leaves these entries byte for byte as they were.
Storage
Falcon keeps each library’s actions in one file in its app data folder:
~/Library/Application Support/Falcon/actions/<key>.json
The folder is eframe::storage_dir("Falcon"), the same app data folder that holds falcon’s saved state and its eagle-backup/ copies. <key> names the library. Eagle’s metadata.json carries no library id, so the key is the 64-bit FNV-1a hash of the library’s canonical path (symlinks resolved, no trailing slash) as 16 lowercase hex digits (falcon_library::actions::library_key). Reopening the same library finds its actions again; two libraries never share a file. A library that is moved or renamed has a new path, so it starts with no actions; its old file stays in the folder.
Falcon writes the file only after an edit, atomically: a temp file in the same actions/ folder, then a rename, so a failed write leaves the old file whole and no partial file behind. It is compact, or indented with two spaces when the file it replaces was. If the file can’t be read, falcon lists nothing and refuses to write over it. Falcon is the only program that writes the file, so it reads it when a library opens or becomes the active one and keeps it in memory between edits; there is no polling.
Nothing in the Actions code touches the library folder. The tests in crates/falcon-app/src/actions/storage_tests.rs open libraries that hold an actions.config.json with known bytes and modification time, then create, edit, run and delete actions, switch libraries and run the quit flush. They check that the file’s bytes and modification time are unchanged, that no file appeared beside it, that the actions landed in the app data folder, and that two libraries don’t see each other’s actions.
The file is a JSON array. Each falcon entry:
| Key | Type | Meaning |
|---|---|---|
id | string | A new item-style id (see Item and folder IDs) |
name | string | The name shown in the menu and the panel |
shortcut | string | An Eagle accelerator such as "Command + Shift + 1", or "" for none |
steps | array | The steps in order, each an object tagged by type |
The step objects:
type | Other keys | Effect |
|---|---|---|
addTags | tags: array of strings | Adds each tag the item lacks (trimmed, at most 1024 characters) |
removeTags | tags: array of strings | Takes these tags off |
clearTag | none | Takes every tag off |
addFolders | folders: array of folder ids | Adds the item to each folder, with the folder’s auto-tags |
removeFolders | folders: array of folder ids | Takes the item out of these folders |
clearFolder | none | Takes the item out of every folder |
rating | rating: 0 to 5 | Sets the star key, or removes it for 0 |
autoTag | none | Adds the tags the default model suggests. Falcon’s own |
describe | replace: boolean, false when absent | Writes the default model’s description to the notes. Falcon’s own |
The rule type names follow the rule names in Eagle’s translations. clearTag and clearFolder are singular, as there.
A sample, which the tests in crates/falcon-library/src/actions.rs read and write back byte for byte:
[{"id":"MA1B2C3D4E5F6","name":"File as reference","shortcut":"Command + Shift + 1","steps":[{"type":"addTags","tags":["reference","to sort"]},{"type":"removeTags","tags":["inbox"]},{"type":"addFolders","folders":["MFOLDER000001"]},{"type":"rating","rating":4}]},{"id":"MA1B2C3D4E5F7","name":"Reset","shortcut":"","steps":[{"type":"clearTag"},{"type":"clearFolder"},{"type":"removeFolders","folders":["MFOLDER000002"]},{"type":"rating","rating":0}]}]
Code
crates/falcon-library/src/actions.rs: the file (ActionsFile, which keeps each entry’s JSON), its place (library_key,store_path), the steps (Step), andLibrary::run_steps.crates/falcon-app/src/actions/:mod.rsholdsActionsState(follows the open library, reads and writes its file instore_dir(), finds shortcut conflicts),app.rsruns actions and routes the menu and the keys,panel.rsdraws the panel andeditor.rsthe editor. A run goes through the reducer asAction::RunSteps, so it lands in the frame’s undo step. A run with AI steps goes on from there toai/background.rs, which keeps it in the workspace, draws its panel and files its result once a frame, and toai.rs, which asks the model on a worker thread and applies the results withLibrary::run_steps_within a transaction of its own.crates/falcon-app/src/menu.rs:ActionEntry,action_enabledand the AppKit Actions menu.
The item menu also has Auto-tag with AI and Write a description with AI, which run that one step on the selection with no saved action, under the same checks, confirm, background run and single undo step. The description step keeps existing notes.
Video playback and the FFmpeg fallback
Off macOS there is no system player, so FFmpeg plays everything. The Windows installer, the Linux packages and the macOS download bundle it. On macOS, Falcon plays video through AVFoundation (crates/falcon-video). That covers mp4, mov and m4v with H.264, HEVC, ProRes and similar codecs. Everything AVFoundation refuses, such as Matroska, WebM, AVI, WMV, FLV, Ogg, MPEG-TS and RealMedia, falls back to FFmpeg (crates/falcon-ffmpeg). This mirrors Eagle, which falls back from Chromium’s <video> element to a bundled mpv player.
FFmpeg is optional. Falcon never links it. The shared libraries are opened at run time with dlopen, or LoadLibrary on Windows (through the libloading crate). Without them Falcon builds and runs, and only the fallback is missing: the viewer’s can’t-play page then says the FFmpeg libraries weren’t found.
What Eagle does
From index.html, preview-window.html, the mediaElement and mpvMediaElement directives in app.bundle.js, js/thumbs/video.js and js/hover-preview/video-hover-preview.js:
| Question | Eagle | Seen or inferred | Falcon |
|---|---|---|---|
| Video formats | ts 3gp 360 afx vap eva mp4 mov m4v webm mkv avi wmv mpg mts flv m2ts f4v open in the video viewer | Seen | Same list (media::VIDEO_EXTENSIONS). Other video files (ogv, rm, vob…) are plain items with a QuickLook thumbnail, as in Eagle |
| When to fall back | The native element errors, reports no size or no duration, or renders no frame within 1.5 s of playing; then useMpvPlayer swaps in <mpv-video> with the same controls | Seen | AVFoundation is tried first. A file it refuses on opening, that fails while loading, or of which it has drawn no picture 1.5 s after opening plays through FFmpeg in the same viewer, keeping volume, mute, speed, loop, place and play state. The time counts from opening, not from playing, since AVFoundation draws a poster picture while paused, and that picture counts. The switch happens once |
| Thumbnail at import | Native <video> first; on failure an <mpv-video> seeks to min(10, duration / 3) seconds and takes a 480-wide JPEG | Seen | QuickLook first for formats macOS reads; FFmpeg first for the others. The FFmpeg picture is taken at the same time and written as Eagle’s 480-wide JPEG. When that picture is black, up to five later ones are tried (falcon’s addition) |
| Hover preview | <mpv-video> for files the native element can’t play, muted, looping, scrubbed by the pointer | Seen | The card plays the file through falcon_video::Video with small pictures, read off the main thread (falcon_video::Prepared), so FFmpeg plays these files there, muted or with their sound, as in the viewer |
Building FFmpeg
scripts/build-ffmpeg.sh makes the libraries. On Linux it needs a C compiler, make, nasm, zlib’s headers and python3-venv, and keeps x86 assembly on, since decoding is too slow without it. On Windows it runs in an MSYS2 UCRT64 shell that can also see cargo and a Windows Python for the venv (MSYS2_PATH_TYPE=inherit), with gcc, binutils, nasm, zlib, make, diffutils, tar, xz and curl; it builds DLLs into target/ffmpeg/lib and refuses any that need a runtime a clean Windows lacks. On macOS it needs clang, make, the Xcode command-line tools and a python3 (3.15 here) to make a venv; no nasm, yasm, cmake or system pkg-config (arm64 needs no assembler beyond clang’s). There it builds dav1d and FFmpeg for macOS 12, the oldest Falcon supports (MACOSX_DEPLOYMENT_TARGET sets another), and builds them again when that changes. Nothing goes outside target/. It:
- makes a venv in
target/ffmpeg-tools/venvand installs pinned wheels into it, each checked against its PyPI SHA-256 (pip --require-hashes --only-binary :all:): meson 1.12.1 and ninja 1.13.2 to build dav1d, and pkgconf 3.0.7.post0, through which FFmpeg’s configure finds dav1d; - downloads
https://downloads.videolan.org/pub/videolan/dav1d/1.5.4/dav1d-1.5.4.tar.xz, checks its SHA-256 (686616b7c69eb88d44459391ab25cac13b6647a3b288835c5784e71c1514a5c5, the hash VideoLAN publishes next to the tarball and the one MacPorts’ dav1d Portfile records), and builds it with meson intotarget/ffmpeg-tools/dav1d(see below); - downloads
https://ffmpeg.org/releases/ffmpeg-7.1.5.tar.xzinto an empty build folder (target/ffmpeg-build, or$FFMPEG_BUILD_DIR); - checks its SHA-256,
de668509caf9e35e3cd162473441fdb29538c6d96ed080292b3cf9e6fc5d558f. ffmpeg.org publishes only a GPG signature; this hash is the one MacPorts and Gentoo record for the signed tarball; - configures, builds and installs into
target/ffmpeg(or$FFMPEG_PREFIX), never system-wide, and copieslibdav1d.7.dyliband dav1d’s licence (COPYING.dav1d) next to the FFmpeg libraries; - refuses to build if
config.hshowsCONFIG_GPL 1orCONFIG_NONFREE 1, ifCONFIG_GPLisn’t 0, iflibavfilteroratempoisn’t in or any other selectable filter is, if any encoder or muxer is on, or if thelibdav1ddecoder didn’t make it intoconfig_components.h, and writesBUILD-INFO.txtwith the versions, sources, hashes and configure lines of FFmpeg and dav1d.
Running it again is cheap. The venv is remade only when a pinned tool version changes, dav1d is rebuilt only when it is missing or its pinned version changes, and FFmpeg only when it is missing, when dav1d was rebuilt, or when the script changes (a stamp in target/ffmpeg/.stamp). FFMPEG_FORCE=1 rebuilds both.
The configure line, also recorded by FFmpeg itself as FFMPEG_CONFIGURATION:
./configure --prefix=<repo>/target/ffmpeg --install-name-dir=@rpath \
--extra-ldflags=-Wl,-rpath,@loader_path --cc=clang --arch=arm64 \
--enable-shared --disable-static --disable-programs --disable-doc --disable-debug \
--disable-avdevice --disable-postproc --disable-filters --enable-filter=atempo \
--disable-encoders --disable-muxers --disable-network --disable-autodetect \
--enable-zlib --enable-libdav1d --pkg-config=pkgconf \
--disable-x86asm --disable-protocols --enable-protocol=file \
--disable-decoders --enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,msmpeg4v1,msmpeg4v2,msmpeg4v3,wmv1,wmv2,wmv3,vc1,h263,h263i,h263p,flv,vp6,vp6a,vp6f,vp8,vp9,theora,rv10,rv20,rv30,rv40,mjpeg,mjpegb,dvvideo,prores,dnxhd,cinepak,huffyuv,ffvhuff,ffv1,rawvideo,indeo3,indeo4,indeo5,svq1,svq3,msvideo1,msrle,qtrle,utvideo,magicyuv,png,libdav1d,aac,aac_latm,mp1,mp1float,mp2,mp2float,mp3,mp3float,ac3,eac3,dca,truehd,mlp,vorbis,opus,flac,alac,wmav1,wmav2,wmapro,wmalossless,cook,ra_144,ra_288,sipr,atrac3,nellymoser,amrnb,amrwb,ape,adpcm_ms,adpcm_ima_wav,adpcm_ima_qt,adpcm_swf,gsm_ms,pcm_s8,pcm_u8,pcm_s16le,pcm_s16be,pcm_s24le,pcm_s24be,pcm_s32le,pcm_s32be,pcm_f32le,pcm_f32be,pcm_f64le,pcm_alaw,pcm_mulaw,pcm_bluray,pcm_dvd \
--disable-demuxers --enable-demuxer=matroska,av1,obu,avi,asf,flv,live_flv,ogg,mpegts,mpegps,mpegvideo,mov,rm,h264,hevc,m4v,vc1,ivf,nut,dv,mxf,yuv4mpegpipe,aac,mp3,flac,wav,w64,aiff,ac3,eac3,dts,truehd,amr,ape
Parsers and bitstream filters keep FFmpeg’s defaults, since the demuxers and decoders above need them (the AV1 parser and the av1_frame_merge and av1_frame_split filters among them). libswscale and libswresample are built, and libavfilter with one filter, atempo (its abuffer and abuffersink graph ends are always built, and no conversion filter is needed since the graph runs on one fixed format); libavdevice and libpostproc are not. libavfilter links only libavutil. Besides dav1d, the build links only macOS’s own libz, CoreFoundation, CoreVideo and CoreMedia.
The libraries carry @rpath install names and an @loader_path rpath, so each finds its siblings whether it is opened from target/ffmpeg/lib or from the app bundle.
AV1 and dav1d
AV1 is decoded by dav1d 1.5.4 (VideoLAN, BSD 2-Clause), FFmpeg’s libdav1d decoder. FFmpeg’s own av1 decoder is left out: it only drives hardware decoders. The matroska (also WebM), mov (MP4) and ivf demuxers read AV1, and the av1 (Annex B) and obu demuxers read raw streams.
dav1d is configured with
meson setup … --prefix=<repo>/target/ffmpeg-tools/dav1d --libdir=lib --buildtype=release \
-Ddefault_library=shared -Denable_asm=true -Denable_tools=false -Denable_tests=false \
-Denable_examples=false -Denable_docs=false
On arm64 its assembly is .S files that clang assembles, so nasm isn’t needed. Meson gives the library the install name @rpath/libdav1d.7.dylib, like the FFmpeg libraries, and libavcodec links it by that name, so it is found through libavcodec’s @loader_path rpath next to it, in target/ffmpeg/lib or in the bundle. dav1d is installed into its own folder first and copied into target/ffmpeg/lib, so rebuilding FFmpeg (which empties target/ffmpeg) leaves the dav1d build alone.
Which files reach dav1d: WebM and Matroska go to FFmpeg first. MP4 goes to AVFoundation first, which decodes AV1 on Macs with an AV1 decoder; when the player fails while loading, falcon switches to FFmpeg, and when falcon_video::Grabber opened a file through AVFoundation but can’t decode its pictures, it switches to FFmpeg for that and every later picture (hover strips and the seek bar’s thumbnail). At import, an MP4 still goes to QuickLook first, which reads AV1 on Macs with an AV1 decoder, and to FFmpeg when QuickLook fails.
Licence
FFmpeg built this way is LGPL-2.1-or-later; dav1d is BSD 2-Clause, which the LGPL build may link (configure’s licence summary still reads LGPL version 2.1 or later, and CONFIG_GPL and CONFIG_NONFREE stay 0). Falcon stays MIT/Apache-2.0: it contains no FFmpeg code, links nothing at build time, and loads unmodified shared libraries that a user can replace. The loader in falcon-ffmpeg/src/ffi.rs reads each library’s *_license() string at run time and refuses any library that is not LGPL (a --enable-gpl or nonfree build), and any major version other than FFmpeg 7.1’s (libavutil 59, libavcodec 61, libavformat 61, libswscale 8, libswresample 5, libavfilter 10). A build made before atempo was added has no libavfilter and is refused too; run the script again.
scripts/bundle.sh copies the six FFmpeg libraries and libdav1d.7.dylib into Falcon.app/Contents/Frameworks, sets their install names to @rpath/…, adds @executable_path/../Frameworks to the executable’s rpaths and signs the bundle ad hoc again (codesign -v --strict --deep passes). The LGPL text, dav1d’s licence (COPYING.dav1d.txt) and BUILD-INFO.txt go in Contents/Resources/FFmpeg, since code signing allows only code in Frameworks. Without target/ffmpeg the bundle builds as before. scripts/package-macos.sh, which makes the macOS download, refuses to go on without it, signs the bundle ad hoc again as a whole, and checks that each binary in it runs on macOS 12 and links only the system’s libraries and the bundle’s own. NOTICE and ATTRIBUTION.md record the versions, licences and sources of FFmpeg and dav1d.
Where the libraries are looked for
In order: $FALCON_FFMPEG_DIR, the bundle’s Contents/Frameworks next to the executable (on Windows, the executable’s own folder), and the workspace’s target/ffmpeg/lib. The first folder holding libavformat.61.dylib (avformat-61.dll on Windows) is used. On Windows the DLLs’ own imports resolve only from that folder and System32 (LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32), so a same-named DLL elsewhere can’t be picked up. falcon_video::ffmpeg_status() reports the version and folder, or why FFmpeg can’t be used.
The bindings
falcon-ffmpeg has its own small FFI rather than ffmpeg-sys, so nothing about FFmpeg is needed at build time. dav1d is never opened directly; libavcodec loads it. ffi::has_decoder asks libavcodec for a decoder by name (libdav1d). It resolves about fifty functions (opening and reading files, decoding, seeking, sws_scale, swr_convert, the atempo graph and their helpers). Structs are declared only as far as the last field read, laid out as FFmpeg 7.1 declares them. The test struct_layouts_match_the_installed_headers compiles a C program against the installed headers and checks every offset and constant the bindings use.
The player
falcon_ffmpeg::Player sits behind the same falcon_video::Video the viewer uses for AVFoundation, so play and pause, seeking, speed, looping, volume and mute, frame stepping, the current time and the duration drive one viewer.
- Threads. A demux thread reads packets, decodes and resamples the sound, stretches it at any speed but normal, and queues video packets. A video thread decodes pictures and converts them to RGBA with libswscale (after the pixel aspect ratio and any display-matrix rotation) into a queue of at most eight frames, fewer for large pictures (96 MB at most). The viewer takes the frame due each time it draws and uploads it as a texture.
- Clock. The sound goes to the app’s rodio output as stereo 32-bit float at 48 kHz. The samples the output takes move the audio clock, which is the master clock while sound plays. With no sound track, no output, the sound muted, or the sound ended first, a wall clock runs the picture and takes over from where the audio clock stopped. Frames due are shown; late ones are skipped.
- Mute. Muting stops decoding the sound and switches to the wall clock; unmuting decodes it again from the playhead.
- Speed. The sound goes through FFmpeg’s
atempofilter (falcon_ffmpeg::Tempo), so it keeps its pitch at any speed, aspreservesPitchdoes in Eagle’s<video>. At normal speed the filter is skipped.atempotakes 0.5 and up, so slower speeds chain it (0.25 is 0.5 × 0.5). Each sample out coversratesamples of the clip, so the audio clock’s arithmetic is the same as for resampled sound, and the time of what comes out is counted from the first sample in (sync::TempoTimeline), as the filter holds some back. The filter’s last window at the very end of the clip may be cut short, about 20 ms of output. Should a replacement FFmpeg lackatempo, the sound is resampled to speed and its pitch moves with it. - Seeking. A seek finds the keyframe before the time (aiming a quarter second early, since FFmpeg rounds to the nearest frame) and decodes forward to the frame on screen at that time. Every seek starts a new generation, and work for an older one is dropped. Seeking past the last frame shows the last frame.
- Frame steps. Forward steps take the next decoded frames; backward steps seek to the earlier frame.
- Frame tools. Set as thumbnail, copy and save frame decode the picture at the playhead again at full size, as with AVFoundation.
Other uses
falcon_video::probe,frame_atandGrabberfall back to FFmpeg, so the seek bar’s hover thumbnail works for these formats, andfalcon_video::Prepareddoes, so the grid’s hover preview does too.falcon_ffmpeg::AudioFiledecodes a file’s sound alone.falcon_library::audio::open, the decoder behind audio playback, waveforms, durations and the tempo, falls back to it (audio/ffmpeg.rs) when symphonia can’t read a file: the soundtrack of an mkv, webm, avi or wmv in the hover preview.- At import,
media::durationasks FFmpeg for the length of video it can’t otherwise read, and FFmpeg draws the thumbnail as described above. Refresh Thumbnail goes the same way.
Tests
- Unit tests cover the clocks, the frame queue, timestamp and seek arithmetic, rotation, black-frame detection and the licence check.
crates/falcon-ffmpeg/tests/ffmpeg.rsruns only whentarget/ffmpegexists (build.rssetsffmpeg_built; otherwise the tests are ignored), and its AV1 tests only when that build includeslibdav1d.7.dylib(ffmpeg_dav1d). It writes its own AVI clips byte by byte (uncompressed BGR pictures and PCM sound, since the build has no encoders) and checks probing, picture grabbing, thumbnails, playback, seeking, stepping, looping, the audio clock, muting, the struct layouts, andatempo: output lengths and the pitch of a tone from 0.1x to 16x, where its sound lands in time, and a 2x run through the player (half the samples, the clock at twice the wall clock, the pitch unchanged, a seek).- AV1:
crates/falcon-ffmpeg/testdata/av1.ivf(1.2 KB) is falcon’s own clip, encoded with rav1e 0.8.1 from 25 flat 64 × 48 pictures whose colours count the frame, keyframes at 0 and 13.falcon_ffmpeg::testing::av1rewraps it as WebM, Matroska and MP4 (every element and box written by hand,av1Cincluded), and the tests check thatlibdav1dis there, then probing, the first frame’s colour, frame-accurate seeks across the keyframe, the import-time thumbnail, the grabber, and the player’s seeks and steps in each container. falcon-library’s import test and the AVFoundation test run the same clips through the import thumbnail and throughfalcon_video(player, grabber,frame_at). crates/falcon-video/tests/avfoundation.rsalso decodes its AVFoundation-written H.264/AAC clips, and a rotated one, through FFmpeg and checks they match.FALCON_VIDEO_SAMPLES=<folder> cargo test -p falcon-ffmpeg --test ffmpeg samples -- --ignored --nocaptureprobes, thumbnails, plays and seeks every clip in a folder, for checks on real files.
Localisation
Falcon’s interface comes in eight languages: English, Simplified Chinese,
Traditional Chinese, Japanese, Korean, German, Spanish and Russian, the
same set Eagle ships. The translations are falcon’s own; none is taken
from Eagle. Preferences ▸ General ▸ Language picks one, or Automatic,
which follows the first of macOS’s preferred languages (System Settings ▸
General ▸ Language & Region) that falcon has, else English. A change
shows at once, menu bar included, and Cancel switches back, as with the
theme. FALCON_LANG=ja (any locale tag) overrides Automatic for one run.
The code lives in crates/falcon-app/src/locale/, the files in
crates/falcon-app/locales/.
How keys work
A string is keyed by its English text, which stays in the code:
| Call | Gives | Use |
|---|---|---|
tr("Move to Trash") | &str | A fixed text |
trf("Moved to {folder}", &[("folder", &name)]) | String | A text with named placeholders |
tr_n(n, "{n} item", "{n} items") | String | A count; picks the language’s plural form and fills {n} |
tr_nf(n, "{n} item in {folder}", "{n} items in {folder}", &[("folder", &f)]) | String | A count with other placeholders |
tr_key("Folders") | the same &'static str | Marks a label in a const or static table; it is translated where it is shown, with tr(label) |
The menu bar and the shortcut list keep their labels in tables
(menu::MENUS, shortcuts::TABLE) and translate them when they draw; the
catalogue test reads those tables directly.
Rules for a new string:
- Pass the whole sentence as one key, with
{name}placeholders; don’t join translated pieces. Format numbers and sizes into the value (("size", &format!("{:.1} MB", mb))). - Translate only what is shown. Text that is saved, compared, used as an id, sent to the HTTP API or written to a library stays English; a label used for both keeps its English function and is translated at each place it is drawn.
- Keys are literals: the test finds them by reading the source, so the
first argument of
tr,trfandtr_key, and both forms oftr_n, must be plain string literals.
A word that needs two translations in two places gets a context: a
lower-case prefix and :: in the key, as in tr("tags::Uncategorized")
(tags in no group) beside tr("Uncategorized") (items in no folder).
English shows the text after ::; en.json lists the context key with
that text. The contexts in use are tags:: (the tag sidebar and tag
picker), match:: (the Any/All picker of the tag and folder filters),
size:: and shape:: (a custom export size and a custom aspect
ratio), model:: (the 3D viewer’s toggles), switcher:: (the library
switcher’s Open heading) and pdf:: (the PDF properties’ File row).
{{ and }} stand for literal braces. An unknown placeholder is left as
it is, so a mistake shows instead of vanishing.
The files
en.json lists every key; it is generated, never edited by hand. Each
other file maps keys to translations, sorted by key:
{
"Move to Trash": "In den Papierkorb legen",
"{n} items": { "one": "{n} Objekt", "other": "{n} Objekte" }
}
A count’s key is its English plural form, and its value an object of the
language’s plural forms: one and other for German and Spanish, one,
few and many for Russian (1, 21: one; 2–4, 22–24: few; the rest:
many), other alone (or a plain string) for Chinese, Japanese and
Korean. A key missing from a file shows in English.
The files are built into the binary with include_str!, so there is
nothing to install, and they are parsed the first time their language is
shown.
Adding or changing a string
- Write the code with
tr,trf,tr_nortr_key. - Run
FALCON_UPDATE_LOCALES=1 cargo test -p falcon-app locale. It rewritesen.jsonfrom the code, drops keys the code no longer uses from the other files, sorts them, and lists for each language the keys still missing, with their English text. - Add the translations and run
cargo test -p falcon-app localeuntil it passes. Note new machine-written translations inlocales/REVIEW.md.
The tests fail when en.json differs from the code, when a file lacks a
key, has one the code doesn’t use, isn’t sorted, drops or adds a
placeholder (German and Spanish may say “one” in words instead of {n}
in the one form, which only 1 takes), or misses a plural form.
Changing an English text changes its key, so every translation of it must be written again; the test lists them.
Adding a language
- Add a variant to
Langinlocale/mod.rswith itscode, its own name (native_name), the tags that select it (from_tag), its file insource, its plural rule (pluralandplural_forms, from CLDR) and, if San Francisco lacks its script, a system font inlocale/fonts.rs. - Create
locales/<code>.jsonwith{}and add it toLang::ALL. - Run the update command above and translate the keys it lists.
Fonts
San Francisco has Latin, Greek and Cyrillic but no Chinese, Japanese or
Korean, so choosing one of those adds a macOS font after it the first
time it is shown: Hiragino Sans (Japanese), Apple SD Gothic Neo
(Korean), Hiragino Sans GB (Simplified Chinese) and Heiti (Traditional
Chinese), from /System/Library/Fonts. A font once added stays for the
session.
What stays English
Every window, pane, menu, dialog, toast and viewer goes through the
catalogue. The default name of a new folder, smart folder group and tag
group (“Untitled”) is translated, as Eagle translates its own, and the
rename templates keep their tokens (%N, %D, %T…) fixed with only
the menu labels translated, again as Eagle does. What stays English is
what isn’t interface text: the other names falcon writes into a library
(“Screenshot - …”, saved filter defaults, frame snapshots), the debug report and the
error log, product and format names (Eagle, Finder, PNG, BPM, ISO),
units and symbols (px, MB, EV, ★), the font viewer’s specimen texts and
messages that come from macOS or a library error unchanged. The
Preferences search matches the translated pane names and block titles,
but each block’s keywords are English. Dates and numbers keep falcon’s
English formats.
API docs
The rustdoc reference for the workspace crates is generated by cargo doc and served next to this book: