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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions apps/desktop-tauri/src-tauri/src/proof_harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,78 @@ pub fn activate(app: &AppHandle) {
}
}

/// Bottom inset (physical px) kept between the proof panel's bottom edge and
/// the monitor work-area bottom (#265).
const PROOF_BOTTOM_INSET_PX: i32 = 8;

/// Mirror of the frontend auto-fit ceiling (`TRAY_MAX_MEASURE_HEIGHT`,
/// logical px in `useTrayPanelLayout.ts`): the proof panel can settle
/// anywhere up to this height.
const PROOF_MAX_SETTLE_HEIGHT_LOGICAL: f64 = 920.0;

/// Justified proof-panel anchor (#265). The harness anchors `main` once and
/// never re-anchors (the flyout-only `reanchor_tray_panel` path is a no-op
/// here, and Win32 ignores `set_max_size` for programmatic resizes), so an
/// anchor computed from the DEFAULT initial size lets a tall auto-fit settle
/// (up to the frontend's 920px cap) push the bottom edge under the taskbar.
/// When `anchor_y + max settle` would exceed `work_bottom - inset`, move the
/// anchor up just enough that even the tallest settle stays fully on screen;
/// otherwise keep the historical anchor unchanged.
fn proof_anchor_y(anchor_y: i32, work_bottom: i32, max_settle_height_px: i32) -> i32 {
let justified = work_bottom - PROOF_BOTTOM_INSET_PX - max_settle_height_px;
anchor_y.min(justified)
}

/// Bottom edge of a settled panel anchored via [`proof_anchor_y`].
#[cfg(test)]
fn proof_settled_bottom(
anchor_y: i32,
settled_height: i32,
work_bottom: i32,
max_settle_height_px: i32,
) -> i32 {
proof_anchor_y(anchor_y, work_bottom, max_settle_height_px) + settled_height
}

/// Calculate a predictable window position for proof captures.
///
/// Proof mode needs a reliable on-screen position. We skip
/// `inferred_tray_panel_position` because its DPI-scaled maths can
/// produce off-screen coords on high-DPI setups.
fn proof_window_position(app: &AppHandle) -> Option<(i32, i32)> {
let (x, y) = proof_window_position_unjustified(app)?;

// #265: justify above the taskbar when the tallest legitimate auto-fit
// settle (the frontend's 920px cap) would otherwise push the bottom edge
// under it — the harness anchors `main` once and never re-anchors, so
// the anchor itself must reserve the settle room.
let Some(window) = app.get_webview_window("main") else {
return Some((x, y));
};
let Some(m) = window
.primary_monitor()
.ok()
.flatten()
.or_else(|| window.available_monitors().ok()?.into_iter().next())
else {
return Some((x, y));
};
let work_area = m.work_area();
let work_bottom = work_area.position.y + work_area.size.height as i32;
let scale = m.scale_factor().max(1.0);
let max_settle = (PROOF_MAX_SETTLE_HEIGHT_LOGICAL * scale) as i32;
let justified_y = proof_anchor_y(y, work_bottom, max_settle);
if justified_y != y {
tracing::info!(
"proof-pos: #265 justified anchor y={y} → {justified_y} \
(work_bottom={work_bottom} max_settle={max_settle})"
);
}
Some((x, justified_y))
}

/// Raw proof-capture anchor, before the #265 above-taskbar justification.
fn proof_window_position_unjustified(app: &AppHandle) -> Option<(i32, i32)> {
if let Some(pos) = shell::tray_panel_position(app) {
return Some(pos);
}
Expand Down Expand Up @@ -204,6 +270,28 @@ mod tests {

static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));

#[test]
fn tall_panel_bottom_edge_never_passes_work_bottom_minus_inset() {
// 1920x1080 proof rig at 100% display scale: historical anchor y=252
// (1040 work bottom - 776 default height - 12 margin); the tallest
// legitimate settle is the frontend auto-fit cap, 920px.
let max_settle = 920;
// y is justified up to 112 so the capped settle bottoms out exactly
// on the inset boundary...
assert_eq!(proof_anchor_y(252, 1040, max_settle), 112);
let bottom = proof_settled_bottom(252, max_settle, 1040, max_settle);
assert!(bottom <= 1040 - PROOF_BOTTOM_INSET_PX);
assert_eq!(bottom, 1032);
// ...and any shorter settle keeps its bottom edge on screen too.
assert!(proof_settled_bottom(252, 780, 1040, max_settle) <= 1040 - PROOF_BOTTOM_INSET_PX);
assert!(proof_settled_bottom(252, 568, 1040, max_settle) <= 1040 - PROOF_BOTTOM_INSET_PX);
// Tall displays where even the capped settle already fits keep the
// historical anchor unchanged.
assert_eq!(proof_anchor_y(252, 2000, max_settle), 252);
// Degenerate anchors below the work bottom move fully above it.
assert_eq!(proof_anchor_y(1500, 1040, max_settle), 112);
}

fn with_proof_mode_env(value: Option<&str>, test: impl FnOnce()) {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CODEXBAR_PROOF_MODE").ok();
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/hooks/useTrayPanelController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ export function useTrayPanelController(state: BootstrapState) {
autoFit: flyoutSize === null && !autoFitKilled,
fixedSize: fixedFlyoutSize,
isOpen: isFlyoutOpen,
zoom: trayScale,
onUserResize: handleUserResize,
});

Expand Down
46 changes: 38 additions & 8 deletions apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ const TRAY_OVERVIEW_MIN_HEIGHT = 200;
const TRAY_DETAIL_MIN_HEIGHT = 420;
const TRAY_DENSE_OVERVIEW_HEIGHT = 776;

/** Scale a measured content height into the window's pixel space. TrayPanel
* renders the surface with `zoom: trayScale` (CSS zoom), but every DOM metric
* the layout pass reads — scrollHeight AND bounding rects — is reported in
* the surface's LOCAL, pre-zoom px (measured identical at 100/150/200%). The
* WIN32 window must be sized in POST-zoom px or tall cards clip below the
* fold (and the zoomed width overflows into a horizontal scrollbar), so the
* raw measure is multiplied by the active zoom before clamping (#265). */
export function scaledContentHeight(rawHeight: number, zoom: number): number {
if (!Number.isFinite(zoom) || zoom <= 0 || zoom === 1) return rawHeight;
return Math.round(rawHeight * zoom);
}

export interface TrayPanelLayoutOptions {
canMeasure: boolean;
denseOverview: boolean;
Expand All @@ -29,6 +41,10 @@ export interface TrayPanelLayoutOptions {
/** Whether the flyout is currently open (surface mode === trayPanel). Used as
* the "just opened" trigger for the fixed-size restore + re-anchor. */
isOpen?: boolean;
/** Active tray-panel zoom factor (the CSS `zoom` TrayPanel applies to the
* surface). Auto-fit scales its measured content height by this before
* clamping — see `scaledContentHeight`. Defaults to 1 (no zoom). */
zoom?: number;
/** Called with the new logical size on a genuine user drag-resize. */
onUserResize?: (width: number, height: number) => void;
}
Expand All @@ -46,6 +62,7 @@ export function useTrayPanelLayout({
autoFit = true,
fixedSize = null,
isOpen = false,
zoom = 1,
onUserResize,
}: TrayPanelLayoutOptions): TrayPanelLayout {
const [layoutReady, setLayoutReady] = useState(false);
Expand Down Expand Up @@ -261,19 +278,32 @@ export function useTrayPanelLayout({
if (run !== resizeRunRef.current) return;

const surfaceRect = surface.getBoundingClientRect();
let contentHeight = Math.max(
surface.scrollHeight,
Math.ceil(surfaceRect.height),
// Everything measured here is in the surface's LOCAL, pre-zoom px;
// scale into post-zoom window px via `scaledContentHeight` at each
// derivation point so the clamped window fits the rendered content.
let contentHeight = scaledContentHeight(
Math.max(surface.scrollHeight, Math.ceil(surfaceRect.height)),
zoom,
);
let maxBottom = surfaceRect.top + contentHeight;
const bodyRect = body?.getBoundingClientRect();
if (bodyRect && bodyRect.height > 0 && bodyRect.bottom > maxBottom) {
maxBottom = bodyRect.bottom;
if (bodyRect && bodyRect.height > 0) {
const scaledBottom =
surfaceRect.top +
scaledContentHeight(bodyRect.bottom - surfaceRect.top, zoom);
if (scaledBottom > maxBottom) {
maxBottom = scaledBottom;
}
}
const footer = surface.querySelector<HTMLElement>(".menu-surface__footer");
const footerRect = footer?.getBoundingClientRect();
if (footerRect && footerRect.height > 0 && footerRect.bottom > maxBottom) {
maxBottom = footerRect.bottom;
if (footerRect && footerRect.height > 0) {
const scaledBottom =
surfaceRect.top +
scaledContentHeight(footerRect.bottom - surfaceRect.top, zoom);
if (scaledBottom > maxBottom) {
maxBottom = scaledBottom;
}
}
contentHeight = Math.ceil(maxBottom - surfaceRect.top) + 4;

Expand Down Expand Up @@ -331,7 +361,7 @@ export function useTrayPanelLayout({
window.clearTimeout(timer);
resizeRunRef.current += 1;
};
}, [autoFit, canMeasure, denseOverview, detailMode, layoutRevision, applySize]);
}, [autoFit, canMeasure, denseOverview, detailMode, layoutRevision, applySize, zoom]);

return { layoutReady, requestLayout };
}
6 changes: 6 additions & 0 deletions apps/desktop-tauri/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -4501,6 +4501,12 @@ html:has(.menu-surface--tray) {
border: 1px solid rgba(255, 255, 255, 0.10);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
overflow: hidden;
/* #265: TrayPanel applies CSS `zoom` on this surface, so it lays out in
windowWidth/zoom local px; the 260px `min-width` floor from .menu-surface
would be multiplied by the zoom on render (→ 390px at 150%) and force a
horizontal scrollbar in the 328px tray window. The window's own 300px
minimum already covers the floor — let the zoomed surface wrap instead. */
min-width: 0;
}
.menu-surface--popout {
width: 100%;
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -850,4 +850,48 @@ describe("TrayPanel provider grid", () => {
);
});
});

it("scales the auto-fit measure by the active tray zoom before clamping (#265)", async () => {
const setSize = vi.fn().mockResolvedValue(undefined);
windowMocks.getCurrentWindow.mockReturnValue({
setSize,
close: vi.fn().mockResolvedValue(undefined),
scaleFactor: vi.fn().mockResolvedValue(1),
onResized: vi.fn().mockResolvedValue(() => {}),
innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }),
});
// jsdom has no layout engine (scrollHeight always reads 0), so pin it
// globally to a deterministic PRE-zoom content height: TrayPanel applies
// `zoom: trayScale` via CSS and the hook must size the window in POST-zoom
// px or tall cards clip below the fold (#265).
const scrollHeight = vi
.spyOn(Element.prototype, "scrollHeight", "get")
.mockReturnValue(505);

const first = renderTrayPanel([provider("codex", "Codex", 61)], {
trayScalePercent: 150,
});

// 505 raw × 1.5 zoom = 757.5 → 758 rounded, + the 4px fudge = 762.
await waitFor(() => {
expect(setSize).toHaveBeenCalledWith(
expect.objectContaining({ width: 328, height: 762 }),
);
});
first.unmount();
setSize.mockClear();

// Same zoom, taller content: round(700 × 1.5) + 4 = 1054 exceeds the
// mocked work-area cap (900 - 16 = 884), so the clamp still wins.
scrollHeight.mockReturnValue(700);
renderTrayPanel([provider("codex", "Codex", 61)], {
trayScalePercent: 150,
});

await waitFor(() => {
expect(setSize).toHaveBeenCalledWith(
expect.objectContaining({ width: 328, height: 884 }),
);
});
});
});
Loading