287 Commits
Author SHA1 Message Date
chris.giteaandClaude Opus 5.5 ded9907bcb perf(ui): weave @FxThread at build time, keep the agent as a safety net
The load-time Byte Buddy agent cost ~420 ms on every launch before the
splash screen could show. FxThreadPlugin now weaves @FxThread at build time
(byte-buddy-maven-plugin, process-classes) with the same inlined advice and
method matcher as the agent, and marks each woven class @FxThreadWoven.

FxThreadAgent.install() only attaches when the application's classes run
from a directory (an IDE run), where an incremental compiler may have
overwritten woven classes, and then weaves only the classes without the
marker, so nothing is woven twice; -Dpholio.fxthread.agent=always|never
overrides the choice. A Maven-built jar starts with no agent: the splash's
first paint moves from ~0.83 s to ~0.47 s.

The measurements behind this are in .claude/plans/startup-time-analysis.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 18:10:48 -04:00
chris.giteaandClaude Opus 5.5 bfa8a523f4 feat(ui): show a splash screen while the desktop interface starts
SplashPreloader is a JavaFX Preloader, registered by GuiBootstrap through
javafx.preloader, so it only exists on the --ui path and shows as soon as
the toolkit is up, before the Spring context is built. It displays the
splash image with the application name, the build version read from
META-INF/build-info.properties, a status line and a progress bar, and fades
out once the main window is actually on screen.

StartupProgressReporter, a BeanPostProcessor added to the context before it
refreshes, turns the context's construction into real progress (beans
initialised over bean definitions) and status lines following the measured
phases: opening the library, loading components, preparing the interface.

The template's placeholder custom.property is dropped from build-info.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 15:49:28 -04:00
chris.giteaandClaude Opus 5.5 d3d942eeaf fix(ui): stack modal dialogs instead of replacing the open one
Opening the credits from the preferences dialog disposed SettingsView,
unwiring its buttons, but left its window open and modal over the shell:
once the credits were closed, nothing could close the preferences any more,
not even quitting the application.

ModalService now keeps a stack of open dialogs. A dialog shown while another
is open is owned by and centred over it, and the one below stays alive;
hide() closes only the topmost and hands control back. StageManager closes
them all at shutdown through the new hideAll(). ModalServiceTest reproduces
the preferences-then-credits sequence with real stages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 15:35:20 -04:00
chris.giteaandClaude Opus 5.5 06e396b153 feat(credits): render the credits from a single markdown file
src/main/resources/userguide/credits.md now lists every shipped library
with its license (read from the dependencies' POMs), the recognition models
and the map and place data. CreditsView, opened from the ? in Settings,
renders it through the new MarkdownPage helper (per-language loading, links
opened in the browser), which the search help popup now shares, and the
release's distrib step copies it to distrib/CREDITS.md in the same commit
as the release note.

MarkdownToBBCode learns [text](url) links, inside bold too. URLs are quoted
in the generated BBCode: AtlantaFX's parser reads a tag ending in "/]" as
self-closing, so a URL with a trailing slash broke the whole page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 15:27:51 -04:00
chris.giteaandClaude Opus 5.5 4e450e88ae feat(tools): add a Wikimedia Commons demo library downloader
tools/demo/fetch-commons-photos.py builds a screenshot library from
Wikimedia Commons: geosearch around ten cities (a ring of nine points, since
each query returns at most 500 files), then only freely licensed JPEG
originals whose own EXIF has a capture date and GPS within range, skipping
placeholder "city centre" positions, at most two per author per city, with
an optional filter on Commons' Quality/Featured pictures. Writes CREDITS.md
and credits.csv, resumes interrupted runs, standard library only. Listed in
the README's useful scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 14:55:39 -04:00
chris.gitea 1e2e205ef4 chore(release): 2026.9.0 2026-09-30 14:26:17 -04:00
chris.giteaandClaude Opus 5.5 572016c5e0 build: drop unused libraries from the executable jar
The repackaged jar no longer ships the OpenJFX jars (every target runtime
provides JavaFX as modules, and they only held the build machine's
natives), Jilt (compile-time annotations, like Lombok) or the JUnit 6 and
AssertJ that devtoolsfx-connector wrongly declares at compile scope:
107 -> 89 libraries, 236.5 -> 223.4 MB. The POM version is reset to 0.0.1.

The README documents what takes space in the jar, and that the desktop
interface needs --ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 14:12:55 -04:00
chris.giteaandClaude Opus 5.5 ce318af010 refactor(cli): make headless the explicit default launch mode
Pholio only opens its window with --ui; without it, it runs the given
command or prints its usage. That was already the behavior, but the help
text, UiModeOptions' explicitChoice() and the LaunchOptionsTest names all
claimed the opposite. UiModeOptions now exposes uiRequested(), LaunchOptions
drops the unreachable "no flag means UI" branch, the help says "Runs
headless by default; pass --ui", and the tests are renamed to what they
assert, plus one checking that framework arguments next to --ui still open
the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 14:12:55 -04:00
chris.giteaandClaude Opus 5.5 844177638a docs: add a README with an overview, dev options and the release command
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 13:31:24 -04:00
chris.giteaandClaude Opus 5.5 223a49f77d feat(release): group the release note by type, then by scope
Sections are now the commit types (features, fixes, ...) with one sub-section
per scope, marked with a label icon instead of the puzzle piece.
ReleaseNotesTest, AGENTS.md and the release profile's comment follow the new
order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 13:25:51 -04:00
chris.gitea fb0232b63c chore(release): 2026.9.1 2026-09-30 13:19:33 -04:00
chris.giteaandClaude Opus 5.5 f75d784bd0 feat(release): generate a release note and publish it with the release
The release profile gains two steps. notes writes
distrib/release_note/release-note-<version>.md from the Conventional
Commits since the previous v<version> tag (last 30 commits for the first
release), grouped by scope then type, duplicate first lines merged, with an
icon per type. publish uses that note as the GitHub release description and
attaches it; after the local tag, distrib commits the note in the distrib
worktree and pushes it. Covered by ReleaseNotesTest.

pom.xml is committed at 2026.9.0, so the next release is 2026.9.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 12:42:02 -04:00
chris.giteaandClaude Opus 5.5 445bf0f622 test: align geo initializer and legacy gallery tests with current behavior
GeoReferenceDataInitializerTest called run() directly to check the
"already installed" skip, which now lives in shouldRun(); assert on
shouldRun() instead, in both directions.

ThumbnailGalleryPaneTest's mid-rechunk selection test failed only in the
full suite: when the throttled re-chunk fired before the second click, the
captured card was detached and never refreshed. Assert on every card
currently attached instead, and on the captured one only while attached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 12:06:43 -04:00
chris.giteaandClaude Opus 5.5 18aa555037 feat(release): add a maven release profile with YYYY.M.N versions
./mvnw -Prelease validate bumps the project version (same month: build
number + 1, otherwise build 0 of the current month), runs a fresh
./mvnw verify, publishes pholio-<version>.jar and its SHA-256 as GitHub
release v<version> in Imag-In/Pholio through gh, then commits pom.xml and
tags v<version> locally. The non-Maven steps live in
tools/release/Release.java, a JDK-only single-file program; the version rule
is covered by ReleaseVersionTest.

AGENTS.md documents the new release flow and that source never goes to
GitHub (orphan distrib worktree, pre-push guard).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 11:58:04 -04:00
chris.giteaandClaude Opus 5.5 2f9ab82b07 chore(git): ignore the nested distrib worktree
distrib/ is a second checkout of this repository on the distrib branch
(git worktree add -b distrib distrib main); its .git file would otherwise
show up as an untracked embedded repository on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 11:23:15 -04:00
chris.gitea 236db6796f chore(distrib): update jdeploy title. 2026-09-30 11:18:56 -04:00
chris.giteaandClaude Opus 5.5 cfe30dca4a style(build): commit the pom.xml layout sortpom produces
sortpom rewrote pom.xml on every package (property order, schemaLocation
indentation, a closing </arg> tag), leaving the working tree dirty after
each build. Committing its output once keeps builds from modifying it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 10:36:46 -04:00
chris.giteaandClaude Opus 5.5 af651f415b fix(packaging): pass jDeploy launch arguments as separate entries
"-Djdeploy.file.limit=8192 --ui" was a single args entry, so the launcher
handed it to main as one unknown argument; LaunchOptions read it as a
subcommand and started headless, printing the usage and exiting with no
window. Each argument now has its own entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 10:33:53 -04:00
chris.giteaandClaude Opus 5.5 369ba04d80 feat(packaging): fit the app icon to the macOS icon grid
The full-bleed logo looked bigger than every other Dock icon. Add
tools/icons/make-macos-icon.py, which centres an 832 px rounded square on a
transparent 1024 px canvas and writes the result as jDeploy's icon.png, as
src/main/resources/images/spo-macos-1024x1024.png (the Dock icon set at
runtime) and as a multi-resolution src/main/packaging/macos/pholio.icns.

Also name the unbundled macOS process "Pholio" through LaunchServices
(MacOsProcessName, via the Foreign Function API) and log why the Dock icon
is skipped instead of returning silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 10:20:52 -04:00
chris.giteaandClaude Opus 5.5 94145f033f feat(gallery): read the all-metadata dialog straight from the file
The details dialog only showed the database copy of MediaMetadata.raw().
Add MetadataReader.readAll, returning (group, name, value) entries for
every tag of every directory, each XMP property and directory errors, and
expose it as MediaAnalysisService.readAllMetadata.

GalleryView runs that read on the METADATA pool as a silent tracked task;
the dialog opens at once with a spinner over the disabled table, then shows
the entries in a group/name/value table or the read error. Closing it
cancels the read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 09:43:55 -04:00
chris.giteaandClaude Opus 5.5 6000820aaa docs(search): explain that named people are found through synced tags
The search guide and the TagContainsFilter/SearchQueryParser javadocs still
claimed there was no person model, so p: only found names typed in as tags.
Naming a person already syncs their name as a tag on every file their face
appears on (PersonTagSyncService), which is what p: and plain words match.
Also point PersonTagSyncService's javadoc at its actual caller,
PersonManagementService, instead of a future panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 09:32:01 -04:00
chris.giteaandClaude Opus 5.5 9792599948 feat(search): add a search syntax guide below the search bar
Document the search bar syntax (free text, r:, p:, path:, combining) as a
user guide in src/main/resources/userguide, in English and French. A new
help icon left of the magnifier toggles SearchHelpPopup, which renders the
page for the current language through MarkdownToBBCode and AtlantaFX's
BBCodeParser and hangs flush below the field as its downward extension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 09:29:46 -04:00
chris.giteaandClaude Opus 5.5 53003cc2de fix(search): drop the quotes around a quoted free-text phrase
A bare quoted phrase such as "New York" was kept as a free-text term with
its quotes, which no file name, tag or place ever contains, so it matched
nothing. It is now one term without its quotes; a malformed prefixed token
still keeps its original text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 09:29:46 -04:00
chris.giteaandClaude Opus 5.5 77eced1f6d feat(geocoding): save only the locality as a photo's place name
Add PlaceMatch.locality and placeName(), which the location dialog now
stores instead of the full address. The local table fills it with the city
name; LocationIQ requests always ask for addressdetails=1&normalizecity=1
and read it from the address breakdown: village if present, otherwise
"suburb, city" (or whichever of the two exists).

Persist the locality in recent_location (V17) so a place re-picked from the
history saves the same locality. V18 backfills pre-existing local rows and
drops web-provider rows whose locality cannot be recovered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-30 09:13:24 -04:00
chris.giteaandClaude Opus 5.5 5d4d94cbba feat(gallery): smooth isolated wheel notches and widen mid-row date gap
Glide a lone mouse-wheel notch (one arriving more than 200 ms after the
previous direct scroll) over a short decaying animation instead of jumping,
and defer the edge-bounce check until that glide has settled. A mouse press
or a new momentum scroll stops the glide.

Triple DATE_SPACING (16 -> 48 px) so a date boundary inside a shared row
reads as a clear seam; the date header columns derive their offsets from the
same constant and stay aligned with the thumbnails below.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-29 22:55:31 -04:00
chris.giteaandClaude Opus 5.5 19f299a575 feat(gallery): make the grid-mode media info overlay slightly translucent
Move the panel surface onto a dedicated backdrop layer behind its content,
since JavaFX CSS cannot add alpha to a looked-up color. The grid overlay
lowers only that layer's opacity, so thumbnails show through faintly while
text and icons stay opaque; the detail-mode panel stays fully opaque.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
2026-09-29 22:52:01 -04:00
chris.giteaandClaude Sonnet 5 d5b90e37e6 feat(gallery): bring the action-selection checkbox and date actions menu back
Reintroduces legacy.GalleryGridCell's grouped-selection UI on top of
JustifiedGalleryPane's own architecture: a hover-revealed circle
checkbox in each thumbnail's top-left corner (click to check —
Google Photos-style shrink-in-place with a rounded, inset thumbnail
and a grey background ring, all CSS-driven via :checked); a per-date
"select all" icon that slides in from the left of the date label on
header hover, reflecting whether every photo of that day is checked;
and a per-date kebab menu (regenerate thumbnails / set GPS / set
rating), visible once at least one of that day's photos is checked.

JustifiedGalleryPane.actionSelection moves from a bare Set<Long> to
a Map<Long, BooleanProperty> (the same shape legacy.ThumbnailGalleryPane
.checkedByFile always used) — needed so the checkbox icon and the
header's own all-checked glyph/menu visibility can react live instead
of being pushed to by hand. filesForDay/actionSelectedFilesForDay/
actionSelectedPropertiesForDay/toggleActionSelectedForDay round out
the per-date half of that state; setOnRegenerateThumbnails/
setOnSetGpsForFiles/setOnSetRatingForFiles are new hooks GalleryView
wires to the same MediaAnalysisService/MediaMetadataEditService calls
legacy's own menu used.

Driven by CSS's own :checked pseudo-class this time, not a plain-Java
node mutation the way navigation selection is — safe now that the
underlying AtlantaFX/Prism rendering bug that pushed navigation
selection away from CSS (GuiBootstrap's prism.dirtyopts=false) is
fixed application-wide, not just for that one node. The two
selections stay fully independent: the navigation frame is always a
card's topmost child, so it never ends up occluded by the checkbox
or the checked-shrink background ring underneath it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sfcVC7rYy5k8XiKCbh4Tr
2026-09-28 23:27:10 -04:00
chris.giteaandClaude Sonnet 5 bf6b501116 refactor(gallery): let the parent layout own all inter-card spacing
JustifiedGridCell.BORDER_BLEED shrank each card's image away from
its own edges on every side, and since JustifiedGalleryPane.SPACING
was 0, that per-card inset was actually the only thing producing any
visible gap between thumbnails — spacing was split between the
parent layout (nominally) and a card-internal padding (in practice).

SPACING is now 6 and the sole source of gap, both across a row
(HBox spacing) and between rows (ListCell top padding) — a card has
no padding/inset of its own any more, its ImageView fills the card's
bounds edge to edge. The navigation-selection frame follows: it now
paints directly over the ImageView's outer edge (SELECTION_FRAME_WIDTH,
renamed from BORDER_BLEED, is a purely visual stroke width with no
tie to layout spacing any more) rather than sitting in a margin that
no longer exists.

Also fixed a stale resizeCard javadoc left over from the width-
stretching mechanism removed in an earlier commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sfcVC7rYy5k8XiKCbh4Tr
2026-09-28 22:55:55 -04:00
chris.giteaandClaude Sonnet 5 b6c9ab8a3f feat(gallery): replace ThumbnailGalleryPane with a justified grid (JustifiedGalleryPane)
New component (JustifiedRow, JustifiedGridItem, JustifiedGridCell,
JustifiedGalleryPane) replaces ThumbnailGalleryPane as the pane
GalleryView instantiates. Single navigation selection only (0 or 1
file, driven uniformly by click and arrow-key browsing) plus a
separate, independent action-selection set (0..N files, keyed by id,
for a future grouped-action trigger not wired up yet) — the old
per-file checkbox/grouped-actions machinery is not ported.

Layout: JustifiedRow.justify groups files into fixed-height rows
(GalleryRow.chunk's own day-merging rule, ported as-is), each entry
rendered at exactly its own natural aspect-ratio width — never
stretched to fill the row, and never squeezed via a growable gap
either (both were tried and reverted: image distortion and an
uneven, growing gap between thumbnails were both worse than a row
occasionally falling short of the container's full width). The one
exception is a single file alone in its row already wider than
availableWidth (an extreme panorama), clamped narrower — cropped,
never stretched. JustifiedGridItem.itemize splits date headers into
their own fixed-height grid item, ahead of the row they label,
keeping exactly two fixed item heights for the ListView (never a
third, row-dependent one) — the same VirtualFlow cell-recycling
desync class of bug legacy.GalleryGridItem's own javadoc documents.

Selection frame: JustifiedGridCell draws navigation selection as a
plain Rectangle added to/removed from its card's children — never a
CSS pseudo-class or Node.visible — after both of those were confirmed
not to reliably repaint a virtualised cell's descendant under
AtlantaFX (see the GuiBootstrap fix below for the actual root cause).

GuiBootstrap now also sets prism.dirtyopts=false before the JavaFX
toolkit starts, working around a genuine Prism dirty-region
computation bug (confirmed against JavaFX 27): a changed pixel region
inside a virtualised ListCell descendant can go unpainted until an
unrelated pointer event forces a larger repaint elsewhere. Isolated
by ruling out every other layer in turn — reproduces regardless of
-Dprism.order (software or hardware rasteriser), disappears entirely
under JavaFX's own near-empty default stylesheet, and the pulse
logger (-Djavafx.pulseLogger=true) named the responsible phase
directly ("Dirty Opts Computed"). Cost measured on a 67 000+ file
library: average paint time per pulse rises from 7.15ms to 10.26ms,
frames missing a 16ms/60Hz budget from 1.3% to 1.8% — real but
imperceptible in manual scroll testing. GuiBootstrapTest guards the
property-setting code itself (a real visual regression test for the
underlying Prism bug is impractical in a headless unit test).

ThumbnailGalleryPane/GalleryGridCell/GalleryRow/GalleryGridItem move
to a new legacy package, package declarations only, zero functional
change (diffed against HEAD to confirm) — kept because they still
carry the grouped-actions mechanism this component doesn't yet have.
GalleryTimelineBar widens to public (used by both packages now, no
duplication, zero behaviour change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sfcVC7rYy5k8XiKCbh4Tr
2026-09-28 22:34:20 -04:00
chris.giteaandClaude Sonnet 5 bc696f9cdc refactor(gallery): render date headers as their own fixed-height grid item
Root fix for the persistent :selected staleness bug: a date's header
used to be extra, taller content optionally rendered inside that
date's own first content row's cell (GalleryRowCell.renderPhotos), so
row height varied depending on whether a row happened to start a
date. On a virtualised ListView with no fixedCellSize, that variable
height was demonstrably (via -Dpholio.skipTheme=true, confirmed to
never reproduce against JavaFX's own near-empty default stylesheet,
only ever AtlantaFX's much heavier one) part of what let VirtualFlow's
cell recycling/positioning fall out of sync with mouse picking badly
enough to leave a stale (or missing) selection highlight on a
thumbnail clicked shortly after another one in the same multi-row
date group.

GalleryRow.chunk() is untouched (still pure width-packing, markers
embedded exactly as before). New GalleryGridItem is a sealed
interface (GalleryRow | HeaderRow) with itemize(), a pure conversion
step that expands chunk()'s output into ThumbnailGalleryPane.rows'
own item sequence: one HeaderRow item (one or more date columns,
their spanWidth computed once here) immediately before whichever
GalleryRow content item it labels. Every content item is now exactly
the same height regardless of which date(s) its entries belong to;
every header item has its own different fixed height.

GalleryRowCell becomes GalleryGridCell, a ListCell<GalleryGridItem>
branching in updateItem between a content half (photosBox, unchanged
card-reuse/thumbnail logic) and a header half (headerStrip, unchanged
label/select-all/actions-menu logic) — a cell tracks which half it
currently holds and only tears down the other on an actual kind
switch, preserving the existing card-reuse optimisation for the
common content-to-content recycling case.

wireDateSelectHover's hover-reveal narrows to the header column's own
hover only (dropping the "or hovering one of its cards" half, no
longer possible now that a date's header and its cards live in
different cells) — a deliberate, minor UX narrowing, confirmed with
the user.

ThumbnailGalleryPane: rows becomes ListView<GalleryGridItem>;
verticalNeighbour/rowIndexOf skip HeaderRow items; updateTimeline/
resolveGroupLabel read HeaderRow items directly instead of scanning
every entry of every row for a marker.

New GalleryGridItemTest covers itemize() in isolation (no JavaFX),
mirroring GalleryRowTest's style. ThumbnailGalleryPaneTest's row-count
assertions gain the one extra header item per date group; the two
header-alignment tests now exercise cross-cell alignment instead of
intra-cell.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 19:42:19 -04:00
chris.giteaandClaude Sonnet 5 8c33ae7572 feat(gallery): add GalleryGridItem, a header/content item split
First step of the fixed-row-height rework: rows.getItems() will hold
a mix of GalleryRow (thumbnails, uniform height) and a new
GalleryGridItem.HeaderRow (one or more date headers, its own uniform
height) instead of a date's header being an optional taller variant
embedded in a content row's own cell. GalleryRow.chunk() itself is
untouched; GalleryGridItem.itemize() is a new pure conversion step
that expands chunk()'s output (markers intact) into this flat item
sequence, mirroring GalleryRowCell's existing headerSpanEnd/spanWidth
scan but computed once per relayout instead of once per cell render.

Not yet wired into ThumbnailGalleryPane/GalleryRowCell — this is the
isolated, independently-testable data-model half of the rework.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 19:22:06 -04:00
chris.giteaandClaude Sonnet 5 5c46168446 fix(gallery): retry the selection-highlight resync across a few pulses
A single applyCss() right after pseudoClassStateChanged (previous
commit) was necessary but not sufficient: confirmed, with
-Dpholio.skipTheme=true, that the same code never shows the bug at
all against JavaFX's own near-empty default stylesheet, only against
AtlantaFX's much larger one — the CSS engine has more work to get
through on the same virtualised cell, widening the window where a
pseudo-class change sits uncommitted past a single synchronous pass.

refreshHighlight/refreshCardHighlighting now retry their own resync
across a few more Platform.runLater pulses (HIGHLIGHT_SETTLE_PULSES /
RELAYOUT_SETTLE_PULSES), the same "needs another pulse to settle"
pattern relayout() already uses for a stale thumbnail paint — closing
the window regardless of how heavy the active stylesheet is, instead
of only working by stylesheet-size coincidence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 18:47:55 -04:00
chris.giteaandClaude Sonnet 5 e910e6c61c fix(gallery): clear stale :hover on a reused card that changed column
Two more manifestations of the same "CSS pseudo-class change doesn't
reliably repaint on a virtualised cell" class of bug reported after
the earlier selection-highlight fix, clicking left-to-right within a
row: a stuck accent glow surviving on a card the pointer had already
left, and the corner selection checkbox not reliably showing/hiding.

Root causes, both tied to card reuse across a re-render:
- A reused card landing at a different column than before has no
  guarantee JavaFX's own MOUSE_EXITED ever fires for its old spot —
  by the time picking next runs the node isn't there anymore, so
  :hover can keep reflecting stale pointer history. Cleared explicitly
  (clearStaleHoverIfMoved) whenever a card's index actually changes.
- pholio.css reveals .thumbnail-overlay-selection via a descendant
  rule keyed off the card's own :hover, not a rule on the icon itself;
  a descendant rule driven only by an ancestor's pseudo-class change
  is a known JavaFX weak spot for not cascading reliably. Forced
  explicitly now via an hoverProperty subscription that runs the
  icon's own applyCss() on every hover change, not just a moved card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 23:43:38 -04:00
chris.giteaandClaude Sonnet 5 788cf0fc9d fix(gallery): force an immediate CSS pass after every selection/checked pseudo-class flip
Toggling :selected/:checked only marks a card CSS-dirty; on a
virtualised ListCell that dirty flag can sit uncommitted until some
unrelated pulse or mouse move forces a CSS pass (Scene's own picking
machinery does this on hit-testing — the exact same mechanism
relayout()'s own javadoc already names for a stale thumbnail paint).
Users saw this as a border staying (or never appearing) blue across
several thumbnails clicked left-to-right, self-correcting only once
the mouse happened to hover over the stale one.

card.applyCss() right after each pseudoClassStateChanged forces that
repaint synchronously instead of leaving it to chance — applied in
GalleryRowCell's own selection/checked handling and in
ThumbnailGalleryPane's scene-wide highlight resync alike.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 23:25:10 -04:00
chris.giteaandClaude Sonnet 5 86bd4da342 fix(gallery): resync selection highlight against the live scene, not per-cell state
GalleryRowCell only refreshed :selected on its own cardsByFile, keyed
to whichever GalleryRow it currently renders. A relayout's row
membership change (e.g. the width-driven re-chunk settling after
files load before this pane is resized) can leave a since-superseded
GalleryRowCell fully attached and painted for a beat before VirtualFlow
recycles or clears it — the same "needs another pulse to settle"
staleness relayout()'s own javadoc already documents for thumbnail
painting, just surfacing here as a stuck or missing selection border
instead: a card built from that stale row never learns selection moved
on, and a freshly built card for the same file in the surviving row
can just as easily miss the highlight the first time around.

ThumbnailGalleryPane now also resyncs :selected on every actually-
attached .photo-card directly (rows.lookupAll), on every selection
change — ground truth from the live scene instead of bookkeeping that
can lag behind VirtualFlow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 23:16:38 -04:00
chris.giteaandClaude Sonnet 5 55c0fd71a6 refactor(gallery): move the detail-mode info panel into PhotoDetailPane
MediaInfoPane used to be a GalleryView-owned HBox sibling for detail
mode, its open state tracked separately from PhotoDetailPane's own
visibility. PhotoDetailPane now owns and docks its own MediaInfoPane
instance directly, so it can never be shown once PhotoDetailPane
itself has closed. photoStack (imageView+overlay) is now the sizing
reference for fitting/hero-transform/slide-in math, since the pane's
own width also includes the docked info panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 22:31:32 -04:00
chris.giteaandClaude Sonnet 5 0304b4ac41 fix(gallery): collapse detail info pane when photo detail closes
mediaInfoPaneDetail's open state tracked mediaInfoOpenDetail alone,
never photoOpen, so closing PhotoDetailPane left it sitting open as
this view's HBox sibling while mediaInfoPaneGrid reappeared over the
grid underneath — two info panels shown side by side on the right.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 22:19:02 -04:00
chris.gitea 5780d66a97 feat(scheduling): let a startup task's shouldRun computation pass data to run
IStartupTask becomes generic: shouldRun() now returns a
ShouldRunResult<T> carrying an optional payload instead of a plain
boolean, and run(T) receives it directly - so a task whose "should I
run" check already computed something expensive doesn't need to
recompute it inside run(). StartupTaskRunner threads shouldRun()'s
result straight into run() via Pair instead of calling shouldRun()
twice (once to filter, once again inside the old run()).

Every existing IStartupTask implementation stays Void-parameterised
for now (GeoReferenceDataInitializer, LibrarySyncTask,
MediaFileProcessingFlagsBackfillTask, MediaFaceDetectionBackfillTask,
PersonOrphanCleanupTask) - only the contract changes in this commit,
not their behavior. MaintenanceRecognitionPane's on-demand rescan()
call is updated to the new run(null) signature.
2026-09-23 21:51:30 -04:00
chris.gitea 1e5b97ea69 chore(gallery): remove temporary missing-thumbnail diagnostic logging
Confirmed no longer reproducible after the retry fix, so the [DIAG]
logging added while investigating (relayout/pulseRowsLayout tracing,
the mouse-move card-paint-state dump, and the decode-pool backlog
counter) is no longer needed.
2026-09-23 21:50:07 -04:00
chris.gitea 1a0fdfc037 fix(gallery): retry thumbnail decode when the file isn't on disk yet
ThumbnailImageCache.request never calls back when the decode returns
null, which is the right contract for a file with no thumbnail at all
but left the placeholder stuck forever for a file whose generation
simply hadn't finished writing to disk yet - confirmed via the
[DIAG] logs, no ASYNC delivery line ever appeared for the stuck file.
GalleryRowCell now retries the request every 500ms (up to 8 times)
until it lands, relying on Caffeine evicting a null-completed entry
so each retry is a genuine new decode attempt.

Also reverts an unrelated regression from the same investigation:
deliverThumbnail used to defer/drop the paint while card.getScene()
was null, on the theory the card was mid-reattachment by VirtualFlow.
That check proved unreliable even for genuinely visible cards, so it
started silently dropping real deliveries - worse than the original
bug, since nothing could make a card show content that was never
painted into it. The paint is unconditional again.
2026-09-23 21:45:26 -04:00
chris.giteaandClaude Sonnet 5 c0c21085c3 debug(gallery): log the decode-pool backlog alongside the paint-state dump
The first [DIAG] capture showed no anomaly at all — correct bounds,
FX thread, matching card, opacity 1 — which either missed the bug or
means the real desync sits below what Node's own properties can show
(retained scene graph vs. what Prism actually flushed to the window).
Before chasing that, rule out the much more mundane possibility:
ThumbnailImageCache.pendingDecodeCount() now tracks in-flight decode
requests, logged alongside dumpCardPaintState's dump — a high count
right when the bug is visible would mean a saturated THUMBNAIL pool
backlog resolving itself shortly after, coincidentally around when a
user reaches for the mouse, rather than a genuine repaint bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-23 21:09:12 -04:00
chris.giteaandClaude Sonnet 5 fd8d3bbf6c debug(gallery): add temporary diagnostic logging for the missing-thumbnail bug
Still reproduces after the previous fix (6abf6b2). Adding targeted,
clearly-marked [DIAG] logging around the actual suspect points, since
guessing a fourth fix blind isn't warranted on a bug this central:

- GalleryRowCell.renderThumbnail/setImageContent: cache hit/miss,
  which thread the async decode callback actually lands on, whether
  the card is still the one cardsByFile currently holds for that file
  (the staleness guard already there), and the card's own bounds/
  visibility/opacity right after content is set.
- ThumbnailGalleryPane.relayout/pulseRowsLayout: file/row counts per
  relayout, realised-cell count per retry pulse, and a full dump of
  every currently-realised photo-card's painted bounds once retries
  are exhausted — plus the same dump on the first mouse move after a
  relayout, to diff the before/after state around whatever a mouse
  move actually changes.

To remove once the real cause is found: every line is tagged [DIAG]
or "TEMPORARY DIAGNOSTIC".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-23 19:35:23 -04:00
chris.giteaandClaude Sonnet 5 6abf6b2d0d fix(gallery): force layout on every realised cell after a relayout
Escalates the existing pulseRowsLayout retry (from 09c58ab): calling
rows.requestLayout() only asks VirtualFlow to reconsider ITS OWN
layout — whether that walks back down into any one already-realised
GalleryRowCell is VirtualFlow's own per-cell dirty tracking to decide,
not something a request at the ListView level can force. A cell whose
position/size VirtualFlow already considers settled can sit with
content that never actually went through a real layoutChildren() pass
at all — its thumbnail decoded, but never painted, until something
else (a mouse move) forces one. That single retry was enough for a
search filter clearing back to a longer list; a folder-tree selection
swapping in an unrelated, differently sized list has kept showing the
same symptom survive it on repeated filtered navigation.

pulseRowsLayout now also calls requestLayout() directly on every
currently-realised .list-cell, marking each one's own needsLayout
regardless of VirtualFlow's own conclusion — reaching rowContainer/
photosBox/each card's ImageView on the next pulse regardless.

Not verified visually — no GUI in this environment. Please retest the
filter-navigation case this was reported against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-23 19:06:47 -04:00
chris.giteaandClaude Sonnet 5 fbb99c4574 fix(gallery): remove MediaInfoPane's actions bar top border
The 1px separator line between the scrolling rows and the actions
bar underneath them read as a stray/misplaced border rather than a
clean divide.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 23:11:54 -04:00
chris.giteaandClaude Sonnet 5 0a5d176a47 fix(gallery): fix hashRow's SelectableTextFlow reporting a 0 preferred width
Found the real cause behind MediaInfoPane's vertical scrollbar: not a
phantom estimate at all, a genuinely too-tall row. SelectableTextFlow's
own constructor sets prefWidth to Region.USE_PREF_SIZE — a sentinel
Region's own doc says applies to minWidth/maxWidth only ("this bound
equals whatever prefWidth computes to"); Region.prefWidth(height) has
no special case for it, so it falls through its own "negative override
-> 0" branch (confirmed against javafx-graphics' own Region source).
Every layout pass reading this flow's preferred width then sees a flat
0, and the 64-character hash wraps one/two characters per line instead
of the 2-3 lines the row was actually designed around.

hashRow now resets it to the ordinary USE_COMPUTED_SIZE right after
construction, restoring real natural-width computation; the existing
minWidth(0) + HGrow.ALWAYS on the same node is what then shrinks it
back down to fit, same as originally intended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 23:03:28 -04:00
chris.giteaandClaude Sonnet 5 4394d1ae3c fix(gallery): decide MediaInfoPane's vbar from real heights, not AS_NEEDED
The previous commit's viewport-width binding fed rows' own content
layout the real, final width, but AS_NEEDED's built-in vbar-needed
check turned out to run its own separate (and still stale) estimate
on top of that — the vertical phantom scrollbar survived regardless,
confirmed against empty space below the hash row, the last one built.

refreshScrollPolicy now decides the vbar itself, comparing rows'
actual already-laid-out height against the viewport's actual height —
ground truth, so no staleness is possible regardless of where exactly
upstream it was coming from. Not a feedback loop despite both listening
to the other's property: every row here is built to wrap, never to
grow shorter with less width, so showing the vbar (which narrows the
viewport) can only ever reconfirm that decision next pass, never flip
it back.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 22:49:47 -04:00
chris.giteaandClaude Sonnet 5 017bb294a4 fix(gallery): drive MediaInfoPane's rows width from the viewport directly
The vertical phantom scrollbar survived the previous fix: fitToWidth
pins rows' width to the viewport, but its own internal vbar-needed
check runs against a width that has not necessarily settled yet —
with wrap-text labels (whose preferred height depends on the width
they wrap at), that stale width produced a too-tall estimate and the
scrollbar it decided on from it never got revisited once layout
actually settled back to empty space below the last row.

fitToWidth is now off; rows.prefWidthProperty() binds straight to
scroll.viewportBoundsProperty()'s own already-final width instead, so
every prefHeight/vbar-needed computation downstream runs against the
real width, not a guess.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 22:45:54 -04:00
chris.giteaandClaude Sonnet 5 e8351cb2d5 fix(gallery): stop MediaInfoPane's horizontal and vertical scrollbars
Both traced to the same root cause: rows (the VBox scroll wraps) was
left at its default min width, which VBox computes from its widest
child — pinned wide here by hashRow's SelectableTextFlow, which by
design keeps its full unwrapped hash on one line. A Region can never
render narrower than its own min width, so fitToWidth was unable to
actually shrink rows down to the viewport, regardless of how much of
it was designed to wrap instead — hence a horizontal scrollbar
despite every row being built to shrink, and a wrong vertical
vbar-needed check riding along on that same too-wide layout.

rows.setMinWidth(0) lets it actually shrink to the viewport width
fitToWidth already asks for; hbarPolicy is now a flat NEVER, since a
fixed-width column like this one should always wrap, never scroll
sideways. That fixed the vbar phantom too, so the previous commit's
reactive height-driven policy toggle (itself a source of feedback-
loop risk between vbarPolicy and viewport width) is no longer needed
and is removed in favour of a plain AS_NEEDED.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 22:40:48 -04:00
chris.giteaandClaude Sonnet 5 15666a6063 feat(gallery): fix MediaInfoPane's phantom scrollbar, add a details dialog
MediaInfoPane's ScrollPane kept showing a vertical scrollbar even
with empty space left below the last row: AS_NEEDED decides that off
a viewport-width estimate taken before layout settles, and with
wrap-text labels in the rows (whose preferred height depends on the
width they wrap at) that estimate regularly landed a hair over the
real viewport height. The policy is now driven directly by comparing
rows' actual height against the scroll viewport's actual height.

While there: pulled the scrolling area apart from a new bottom action
bar, docked outside it so it stays visible regardless of how many
rows fit above — currently home to one icon, opening a new "all
metadata" dialog (MediaMetadataDetailsView) listing every entry of
MediaMetadata.raw() in a plain two-column name/value table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 22:32:33 -04:00
chris.giteaandClaude Sonnet 5 bb6dcb1d38 feat(gallery): remember the last 10 locations picked in the GPS dialog
GeoLocationEditView now shows a last-used-first-out list of up to 10
previously picked places ahead of the live search, separated by a
row once both sides are non-empty, and re-picking an already-listed
place moves it back to the front instead of duplicating it. Recorded
on Save via the new RecentLocationService, backed by a per-library
"recent_location" table pruned to the 10 most recent on every write —
the same trim-on-write pattern SyncReportService already uses for
sync reports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016okXfGz39FtQawFWDYpQ5C
2026-09-22 21:47:54 -04:00