fix(shell): align the search bar with the gallery below it

HeaderBar's centre region defaults to centring on the whole bar's own
width, regardless of how wide the leading/trailing regions actually
are — search floated visibly off from NavigationSidebar's own right
edge as a result. CENTER_LEFT switches it to fill the entire leftover
span instead, left-aligned within it; a dynamic left margin then places
it exactly at NavigationSidebar.TOTAL_WIDTH.

Margin is computed from the brand's actual bounds, not from pholio.css's
own header padding: on macOS, HeaderBar also reserves leading space of
its own for the native traffic-light buttons, which that padding alone
doesn't account for and which disappears in fullscreen.
This commit is contained in:
2026-09-16 20:40:44 -04:00
parent 4b605cdbf9
commit f739d762a9
2 changed files with 44 additions and 19 deletions
@@ -13,9 +13,9 @@ import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.HeaderBar;
import javafx.scene.layout.HeaderDragType;
import javafx.scene.layout.Priority;
import javafx.stage.FileChooser;
import javafx.util.Duration;
import org.icroco.pholio.infra.i18n.I18nService;
import org.icroco.pholio.infra.library.LibraryCatalog;
import org.icroco.pholio.infra.library.LibraryService;
import org.icroco.pholio.infra.preferences.AppPreferences;
@@ -25,7 +25,6 @@ import org.icroco.pholio.ui.common.FxThread;
import org.icroco.pholio.ui.common.SubscriptionScope;
import org.icroco.pholio.ui.common.UiComponent;
import org.icroco.pholio.ui.event.NavigateToDestinationEvent;
import org.icroco.pholio.infra.i18n.I18nService;
import org.icroco.pholio.ui.theme.EColorMode;
import org.icroco.pholio.ui.theme.ThemeManager;
import org.kordamp.ikonli.feather.Feather;
@@ -63,7 +62,7 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
private static final String AVAILABLE_KEY = "available";
/**
* Caps the selector so a long database name cannot push the search bar off centre.
* Caps the selector so a long database name cannot push the search bar out of the header entirely.
*/
private static final double SELECTOR_MAX_WIDTH = 180;
@@ -99,8 +98,32 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
this.gallerySearchState = gallerySearchState;
getStyleClass().add("app-header-bar");
setLeft(buildBrand());
setCenter(buildCenter());
HBox brand = buildBrand();
TextField search = buildSearch();
setLeft(brand);
setCenter(search);
// HeaderBar's own default centre alignment (null, or any *_CENTER Pos) centres a child on the
// bar's whole width, independently of how wide the leading/trailing regions actually are — which
// is exactly what kept the search field from lining up with NavigationSidebar's own right edge
// (and so with the gallery grid starting below it): a search bar floating a few dozen pixels off
// from the content it searches read as a misalignment bug on sight. CENTER_LEFT is what switches
// HeaderBar over to its other layout branch instead — the centre child fills the *entire* leftover
// span between the leading and trailing regions (not just centres within it), left-aligned inside
// that span; the left margin set below is what then places it exactly at NavigationSidebar's own
// edge rather than flush against the brand.
HeaderBar.setAlignment(search, Pos.CENTER_LEFT);
// brand's actual right edge (its bounds, not just its width) + this margin == NavigationSidebar's
// own TOTAL_WIDTH — measured from where brand really ends, not computed from pholio.css's own
// padding, because that padding is not the only offset at play here: on macOS, HeaderBar reserves
// additional leading space of its own for the native traffic-light buttons, before its content
// ever starts, and how much varies by platform (and disappears in fullscreen). boundsInParent
// already reflects all of that, whatever it happens to be, plus app.name's own translated width.
Runnable alignSearchWithGallery = () -> HeaderBar.setMargin(search, new Insets(0, 12, 0,
Math.max(0, NavigationSidebar.TOTAL_WIDTH - brand.getBoundsInParent().getMaxX())));
alignSearchWithGallery.run();
brand.boundsInParentProperty().addListener((obs, oldBounds, newBounds) -> alignSearchWithGallery.run());
setRight(buildActions());
}
@@ -293,13 +316,15 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
/**
* Just the search bar now — the module selector moved out entirely (its destinations live in
* {@link NavigationSidebar} instead), so this is free to take up most of the header's width.
* {@link NavigationSidebar} instead), so this is free to take up most of the header's width. Set as
* {@code HeaderBar}'s own {@code center} node directly (see the constructor for why, and for the
* alignment/margin that actually positions it) — no wrapping {@code HBox} needed, since a
* {@code CENTER_LEFT}-aligned centre child is resized to fill its whole span on its own.
*/
private HBox buildCenter() {
private TextField buildSearch() {
search.promptTextProperty().bind(i18n.binding("action.search.prompt"));
HBox.setHgrow(search, Priority.ALWAYS);
// Unstyled, a TextField's own padding makes it taller than the header bar comfortably fits.
search.getStyleClass().add(Styles.SMALL);
search.getStyleClass().addAll(Styles.TEXT_NORMAL, Styles.ROUNDED);
HeaderBar.setDragType(search, HeaderDragType.NONE);
Tooltip searchTooltip = new Tooltip();
searchTooltip.textProperty().bind(i18n.binding("action.search.tooltip"));
@@ -327,13 +352,7 @@ public class AppHeaderBar extends HeaderBar implements Disposable {
publisher.publishEvent(new NavigateToDestinationEvent(ENavigationDestination.PHOTOS));
});
HBox centre = new HBox(search);
centre.setAlignment(Pos.CENTER);
centre.setPadding(new Insets(0, 12, 0, 12));
// Blank space in the centre block still moves the window; only the controls inside opt out.
HeaderBar.setDragType(centre, HeaderDragType.DRAGGABLE_SUBTREE);
return centre;
return search;
}
/**
@@ -69,6 +69,12 @@ public class NavigationSidebar extends VBox implements Disposable {
*/
private static final double VIEWPORT_GAP = 8;
/**
* The sidebar's total on-screen width, gap included — where the main viewport (and so the gallery)
* actually starts. {@code AppHeaderBar} reads this to line its own search field up with it.
*/
public static final double TOTAL_WIDTH = WIDTH + VIEWPORT_GAP;
/**
* Every entry's fixed leading column — an invisible spacer or a chevron, see the class javadoc.
*/
@@ -95,9 +101,9 @@ public class NavigationSidebar extends VBox implements Disposable {
this.searchState = searchState;
getStyleClass().add("navigation-sidebar");
setMinWidth(WIDTH + VIEWPORT_GAP);
setPrefWidth(WIDTH + VIEWPORT_GAP);
setMaxWidth(WIDTH + VIEWPORT_GAP);
setMinWidth(TOTAL_WIDTH);
setPrefWidth(TOTAL_WIDTH);
setMaxWidth(TOTAL_WIDTH);
setPadding(new Insets(0, VIEWPORT_GAP, 0, 0));
Entry photos = expandableEntry(ENavigationDestination.PHOTOS, libraryFolderTree,