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.