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
87 changes: 19 additions & 68 deletions src-tauri/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,24 +350,13 @@ const WINDOW_BOTTOM_PADDING: f64 = 32.0;
pub(crate) const SCREEN_MARGIN: f64 = 16.0;
/// macOS menu bar height approximation (logical pts).
pub(crate) const MENU_BAR_HEIGHT: f64 = 24.0;
/// Minimum screen space (logical pts) needed below the initial window bottom
/// for the conversation to expand freely. Derived from the frontend's
/// `max-h-[600px]` CSS constraint plus a small safety margin.
/// When less space is available, the window is pinned to grow upward instead.
const UPWARD_GROWTH_THRESHOLD: f64 = 600.0;

/// Result of the window placement calculation.
#[derive(Debug, Clone, PartialEq)]
pub struct WindowPlacement {
/// Logical X of the window's top-left corner.
pub x: f64,
/// Logical Y of the window's top-left corner.
pub y: f64,
/// When `Some`, the bar was flipped **above** the selection because the screen
/// bottom was too close. The value is the logical Y the window bottom should
/// stay pinned to as the conversation grows (so the frontend can reposition
/// upward by computing `y = anchor_bottom_y - current_window_height`).
pub anchor_bottom_y: Option<f64>,
}

/// Returns the top-center position for the no-selection spawn point.
Expand All @@ -381,11 +370,7 @@ fn top_center(
let x_max = (screen_width - window_width - SCREEN_MARGIN).max(x_min);
let x = ((screen_width - window_width) / 2.0).clamp(x_min, x_max);
let y = MENU_BAR_HEIGHT + SCREEN_MARGIN + 120.0;
WindowPlacement {
x,
y,
anchor_bottom_y: None,
}
WindowPlacement { x, y }
}

/// Positions the window to the right of `anchor_x / anchor_bottom_y`, flipping
Expand Down Expand Up @@ -421,25 +406,19 @@ fn anchor_near(
let below_y = anchor_bottom_y - ANCHOR_OFFSET_Y;

if below_y + window_height <= screen_height - SCREEN_MARGIN {
// Enough room below → normal downward placement.
// Enough room below: place just below the selection.
WindowPlacement {
x,
y: below_y.max(y_min),
anchor_bottom_y: None,
}
} else {
// Flip above: shift the window bottom down by WINDOW_BOTTOM_PADDING so
// the bar's visible content bottom (not the transparent window edge) sits
// ANCHOR_OFFSET_Y pts above anchor_top_y. Clamped to screen_height so
// the window never extends off the screen's lower edge.
// Not enough room below: flip above the selection. Shift by
// WINDOW_BOTTOM_PADDING so the bar's visible content bottom (not the
// transparent window edge) sits ANCHOR_OFFSET_Y pts above anchor_top_y.
let fixed_bottom =
(anchor_top_y - ANCHOR_OFFSET_Y + WINDOW_BOTTOM_PADDING).min(screen_height);
let y = (fixed_bottom - window_height).max(y_min);
WindowPlacement {
x,
y,
anchor_bottom_y: Some(fixed_bottom),
}
WindowPlacement { x, y }
}
}

Expand All @@ -454,8 +433,8 @@ pub fn calculate_window_position(
window_width: f64,
window_height: f64,
) -> WindowPlacement {
let placement = if let Some(rect) = ctx.bounds {
// AX provided full bounds → anchor to the end of the selection.
if let Some(rect) = ctx.bounds {
// AX provided full bounds: position near the end of the selection.
anchor_near(
rect.x + rect.width,
rect.y + rect.height,
Expand Down Expand Up @@ -485,23 +464,7 @@ pub fn calculate_window_position(
} else {
// No selection → top center of screen.
top_center(screen_width, screen_height, window_width, window_height)
};

// Secondary check: if the flip logic above did not set an anchor, determine
// whether there is enough room below for the conversation to expand fully.
// If not, pin the window bottom so the conversation can grow upward instead
// of being clipped by the screen edge.
if placement.anchor_bottom_y.is_none() {
let initial_bottom = placement.y + window_height;
let space_below = screen_height - SCREEN_MARGIN - initial_bottom;
if space_below < UPWARD_GROWTH_THRESHOLD {
return WindowPlacement {
anchor_bottom_y: Some(initial_bottom),
..placement
};
}
}
placement
}

// ─── Tests ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -549,12 +512,10 @@ mod tests {
let p = calculate_window_position(&ctx_no_selection(), SW, SH, WW, WH);
assert_eq!(p.x, (SW - WW) / 2.0);
assert_eq!(p.y, MENU_BAR_HEIGHT + SCREEN_MARGIN + 120.0);
assert_eq!(p.anchor_bottom_y, None);
}

#[test]
fn text_with_no_bounds_and_no_mouse_falls_back_to_top_center() {
// Same top-center position — no anchor needed since the bar grows downward.
let ctx = ActivationContext {
selected_text: Some("hello world".to_string()),
bounds: None,
Expand All @@ -565,40 +526,34 @@ mod tests {
let x_max = (SW - WW - SCREEN_MARGIN).max(x_min);
assert_eq!(p.x, ((SW - WW) / 2.0).clamp(x_min, x_max));
assert_eq!(p.y, MENU_BAR_HEIGHT + SCREEN_MARGIN + 120.0);
assert_eq!(p.anchor_bottom_y, None);
}

#[test]
fn text_with_no_bounds_uses_mouse_as_anchor() {
// Mouse at (400, 300). placement.y ≈ 298. space_below = 900-16-378 = 506 < 600 → anchor.
fn text_with_no_bounds_uses_mouse_position() {
// Mouse at (400, 300). below_y = 298. Room below → normal placement.
let ctx = ctx_text_no_bounds_with_mouse(400.0, 300.0);
let p = calculate_window_position(&ctx, SW, SH, WW, WH);
assert_eq!(p.x, 400.0 + ANCHOR_OFFSET_X);
let expected_y = 300.0 - ANCHOR_OFFSET_Y;
assert!((p.y - expected_y).abs() < 0.01);
assert_eq!(p.anchor_bottom_y, Some(expected_y + WH));
}

#[test]
fn selection_with_room_anchors_to_end() {
// Selection at x=100, y=300, w=80, h=20 → end at (180, 320).
// placement.y ≈ 318. space_below = 900-16-398 = 486 < 600 → anchor pinned.
fn selection_positions_near_end() {
// Selection at x=100, y=300, w=80, h=20. End at (180, 320).
let ctx = ctx_with_bounds(100.0, 300.0, 80.0, 20.0);
let p = calculate_window_position(&ctx, SW, SH, WW, WH);
assert_eq!(p.x, 180.0 + ANCHOR_OFFSET_X);
let expected_y = 320.0 - ANCHOR_OFFSET_Y;
assert!((p.y - expected_y).abs() < 0.01);
assert_eq!(p.anchor_bottom_y, Some(expected_y + WH));
}

#[test]
fn no_anchor_when_plenty_of_room_below() {
// Selection near top of screen: placement.y ≈ 18. space_below = 900-16-98 = 786 > 600.
fn selection_near_top_clamps_to_menu_bar() {
// Selection near top of screen: below_y = 18, clamped to y_min = 40.
let ctx = ctx_with_bounds(100.0, 0.0, 80.0, 20.0);
let p = calculate_window_position(&ctx, SW, SH, WW, WH);
// below_y = 20-2 = 18, clamped to y_min = 40.
assert_eq!(p.y, MENU_BAR_HEIGHT + SCREEN_MARGIN);
assert_eq!(p.anchor_bottom_y, None);
}

#[test]
Expand All @@ -621,15 +576,11 @@ mod tests {

#[test]
fn y_flips_above_when_selection_near_screen_bottom() {
// Selection: y=870, h=20 → bottom=890.
// below_y = 888. 888+80=968 > 900-16=884 → flip above.
// fixed_bottom = min(870 - 2 + 32, 900) = min(900, 900) = 900.
// y = (900-80).max(40) = 820.
// Visible content bottom = 900 - WINDOW_BOTTOM_PADDING(32) = 868 → 2px above sel top(870).
// Selection: y=870, h=20. below_y=888. 888+80=968 > 884 → flip above.
// fixed_bottom = min(870-2+32, 900) = 900. y = (900-80).max(40) = 820.
let ctx = ctx_with_bounds(100.0, 870.0, 80.0, 20.0);
let p = calculate_window_position(&ctx, SW, SH, WW, WH);
assert_eq!(p.y, 820.0);
assert_eq!(p.anchor_bottom_y, Some(900.0));
}

#[test]
Expand All @@ -639,7 +590,6 @@ mod tests {
let ctx = ctx_with_bounds(100.0, 10.0, 80.0, 20.0);
let p = calculate_window_position(&ctx, SW, SH, WW, WH);
assert_eq!(p.y, MENU_BAR_HEIGHT + SCREEN_MARGIN);
assert_eq!(p.anchor_bottom_y, None);
}

#[test]
Expand All @@ -657,11 +607,12 @@ mod tests {
}

#[test]
fn very_tall_screen_no_anchor_bottom() {
fn very_tall_screen_positions_below() {
let ctx = ctx_with_bounds(100.0, 100.0, 80.0, 20.0);
let tall_screen = 2000.0;
let p = calculate_window_position(&ctx, SW, tall_screen, WW, WH);
assert_eq!(p.anchor_bottom_y, None);
// below_y = 118. 118+80=198 < 2000-16=1984 → placed below.
assert_eq!(p.y, 120.0 - ANCHOR_OFFSET_Y);
}

#[test]
Expand Down
86 changes: 40 additions & 46 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,45 +91,40 @@ static OVERLAY_INTENDED_VISIBLE: AtomicBool = AtomicBool::new(false);
/// registered, so the show event is guaranteed to have a listener.
static LAUNCH_SHOW_PENDING: AtomicBool = AtomicBool::new(true);

/// Fixed-bottom anchor emitted when the bar is positioned above the selection.
/// The frontend pins the window bottom to `bottom_y` as the conversation grows.
#[derive(Clone, serde::Serialize)]
struct WindowAnchor {
/// Logical X of the window top-left (preserved during height changes).
x: f64,
/// Logical Y the window bottom must stay pinned to.
bottom_y: f64,
/// Minimum Y the window top may reach (monitor top + menu-bar clearance).
/// On above-monitors this is negative, preventing the frontend's clamp
/// from yanking the window back onto the primary display.
min_y: f64,
}

/// Payload emitted to the frontend on every visibility transition.
#[derive(Clone, serde::Serialize)]
struct VisibilityPayload {
/// "show" or "hide-request"
state: &'static str,
/// Selected text captured at activation time, if any.
selected_text: Option<String>,
/// Present when the window was flipped above the selection. The frontend
/// uses this to keep the window bottom anchored as the chat grows.
window_anchor: Option<WindowAnchor>,
/// Logical X of the window at show time. Used with `window_y` and
/// `screen_bottom_y` to decide growth direction, and as the pinned X
/// coordinate for `set_window_frame` calls during upward growth.
window_x: Option<f64>,
/// Logical Y of the window top-left at show time.
window_y: Option<f64>,
/// Logical Y of the screen bottom edge (monitor origin + height).
screen_bottom_y: Option<f64>,
}

/// Emits a visibility transition to the frontend animation controller.
fn emit_overlay_visibility(
app_handle: &tauri::AppHandle,
state: &'static str,
selected_text: Option<String>,
window_anchor: Option<WindowAnchor>,
window_x: Option<f64>,
window_y: Option<f64>,
screen_bottom_y: Option<f64>,
) {
let _ = app_handle.emit(
OVERLAY_VISIBILITY_EVENT,
VisibilityPayload {
state,
selected_text,
window_anchor,
window_x,
window_y,
screen_bottom_y,
},
);
}
Expand Down Expand Up @@ -180,10 +175,6 @@ mod cg_displays {
}
}

/// Minimum Y offset from the top of any monitor — menu bar plus edge margin.
/// Must match `MENU_BAR_HEIGHT + SCREEN_MARGIN` in `context.rs`.
const MONITOR_TOP_CLEARANCE: f64 = 40.0;

/// Returns the Quartz-coordinate bounds of the display containing
/// `(global_x, global_y)`, falling back to the main display.
#[cfg(target_os = "macos")]
Expand All @@ -192,7 +183,7 @@ fn find_target_monitor(global_x: f64, global_y: f64) -> (f64, f64, f64, f64) {
}

/// Returns Quartz-coordinate bounds of the main display as a fallback
/// when no anchor point is available.
/// when no positioning context is available.
#[cfg(target_os = "macos")]
fn monitor_info_fallback() -> (f64, f64, f64, f64) {
cg_displays::main_display()
Expand Down Expand Up @@ -255,26 +246,21 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo
let global = crate::context::WindowPlacement {
x: p.x + mon_x,
y: p.y + mon_y,
anchor_bottom_y: p.anchor_bottom_y.map(|y| y + mon_y),
};

let _ = window.set_position(tauri::Position::Logical(tauri::LogicalPosition::new(
global.x, global.y,
)));
// Menu-bar clearance in global coordinates for this monitor.
let global_min_y = mon_y + MONITOR_TOP_CLEARANCE;
Some((global, global_min_y))
let screen_bottom = mon_y + screen_h;
Some((global, screen_bottom))
} else {
None
};

let window_anchor = placement.and_then(|(p, min_y)| {
p.anchor_bottom_y.map(|bottom_y| WindowAnchor {
x: p.x,
bottom_y,
min_y,
})
});
let (window_x, window_y, screen_bottom_y) = match &placement {
Some((p, sb)) => (Some(p.x), Some(p.y), Some(*sb)),
None => (None, None, None),
};

match app_handle.get_webview_panel("main") {
Ok(panel) => {
Expand All @@ -283,7 +269,9 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo
app_handle,
OVERLAY_VISIBILITY_SHOW,
selected_text,
window_anchor,
window_x,
window_y,
screen_bottom_y,
);
}
Err(e) => {
Expand All @@ -298,7 +286,14 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo
/// window hide is deferred until the frontend exit animation completes.
fn request_overlay_hide(app_handle: &tauri::AppHandle) {
if OVERLAY_INTENDED_VISIBLE.swap(false, Ordering::SeqCst) {
emit_overlay_visibility(app_handle, OVERLAY_VISIBILITY_HIDE_REQUEST, None, None);
emit_overlay_visibility(
app_handle,
OVERLAY_VISIBILITY_HIDE_REQUEST,
None,
None,
None,
None,
);
}
}

Expand All @@ -316,7 +311,14 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
emit_overlay_visibility(app_handle, OVERLAY_VISIBILITY_SHOW, ctx.selected_text, None);
emit_overlay_visibility(
app_handle,
OVERLAY_VISIBILITY_SHOW,
ctx.selected_text,
None,
None,
None,
);
}
}

Expand Down Expand Up @@ -854,12 +856,4 @@ mod tests {
assert_eq!(OVERLAY_LOGICAL_WIDTH, 600.0);
assert_eq!(OVERLAY_LOGICAL_HEIGHT_COLLAPSED, 80.0);
}

#[test]
fn monitor_top_clearance_matches_context() {
assert_eq!(
MONITOR_TOP_CLEARANCE,
crate::context::MENU_BAR_HEIGHT + crate::context::SCREEN_MARGIN
);
}
}
Loading