diff --git a/plugins/windows/src/window/floating_bar.rs b/plugins/windows/src/window/floating_bar.rs index 3a9b22f3475..db0f3edd16d 100644 --- a/plugins/windows/src/window/floating_bar.rs +++ b/plugins/windows/src/window/floating_bar.rs @@ -100,29 +100,66 @@ pub(crate) mod layout { } } - pub fn top_right_origin( + pub fn bottom_center_origin( work_x: f64, work_y: f64, work_width: f64, - _work_height: f64, + work_height: f64, window_width: f64, - _window_height: f64, + window_height: f64, ) -> (f64, f64) { ( - work_x + work_width - window_width - SCREEN_MARGIN, - work_y + SCREEN_MARGIN, + work_x + (work_width - window_width) / 2.0, + work_y + work_height - window_height - SCREEN_MARGIN, ) } - pub fn resize_keep_top_right( + #[cfg(any(test, not(target_os = "macos")))] + pub fn expands_upward(y: f64, height: f64, work_y: f64, work_height: f64) -> bool { + y - work_y > work_y + work_height - y - height + } + + #[cfg(any(test, not(target_os = "macos")))] + pub fn forget_moved_expansion( + expansion: &mut Option<((f64, f64, f64, f64), (f64, f64))>, + position: (f64, f64), + ) { + if expansion.is_some_and(|(_, origin)| { + (position.0 - origin.0).abs() >= 0.5 || (position.1 - origin.1).abs() >= 0.5 + }) { + *expansion = None; + } + } + + pub fn collapse_anchor( + current: (f64, f64, f64, f64), + expansion: Option<((f64, f64, f64, f64), (f64, f64))>, + ) -> (f64, f64, f64, f64) { + if let Some((compact, expanded)) = expansion { + if (current.0 - expanded.0).abs() < 0.5 && (current.1 - expanded.1).abs() < 0.5 { + return compact; + } + } + current + } + + pub fn resize_anchored( x: f64, y: f64, current_width: f64, - _current_height: f64, + current_height: f64, next_width: f64, - _next_height: f64, + next_height: f64, + expands_upward: bool, ) -> (f64, f64) { - (x + current_width - next_width, y) + ( + x + (current_width - next_width) / 2.0, + if expands_upward { + y + current_height - next_height + } else { + y + }, + ) } pub fn clamp_to_work_area( @@ -257,12 +294,15 @@ mod platform { use tauri_specta::Event; use super::layout::{ - clamp_to_work_area, container_size, is_expanded, resize_keep_top_right, top_right_origin, + bottom_center_origin, clamp_to_work_area, container_size, expands_upward, is_expanded, + resize_anchored, }; use super::{FloatingBarState, WINDOW_LABEL}; use crate::Error; static APP_HANDLE: OnceLock> = OnceLock::new(); + static EXPANDS_UPWARD: Mutex = Mutex::new(true); + static EXPANSION: Mutex> = Mutex::new(None); static LAST_STATE: Mutex> = Mutex::new(None); pub fn set_app_handle(app: tauri::AppHandle) { @@ -362,6 +402,16 @@ mod platform { .disable_drag_drop_handler() .build()?; + let moved_window = window.clone(); + window.on_window_event(move |event| { + if let tauri::WindowEvent::Moved(position) = event + && let Ok(scale) = moved_window.scale_factor() + && let Ok(mut expansion) = EXPANSION.try_lock() + { + let position = position.to_logical::(scale); + super::layout::forget_moved_expansion(&mut expansion, (position.x, position.y)); + } + }); crate::window::exclude_from_capture(&window); Ok(window) @@ -382,27 +432,58 @@ mod platform { let size_changed = (current_size.width - width).abs() >= 0.5 || (current_size.height - height).abs() >= 0.5; - if size_changed { - window.set_size(Size::Logical(next_size))?; + let mut expansion = EXPANSION + .lock() + .map_err(|_| Error::PanelError("floating bar placement lock poisoned".to_string()))?; + let mut previous = ( + current_position.x, + current_position.y, + current_size.width, + current_size.height, + ); + if force_default_position { + *expansion = None; + } else if height < current_size.height { + previous = super::layout::collapse_anchor(previous, expansion.take()); } - - let (next_x, next_y) = - if force_default_position || current_position.x == 0.0 && current_position.y == 0.0 { - default_origin(window, width, height)? - } else if size_changed { - resize_keep_top_right( - current_position.x, + let (next_x, next_y) = if force_default_position { + if let Ok(mut direction) = EXPANDS_UPWARD.lock() { + *direction = true; + } + default_origin(window, width, height)? + } else if size_changed { + let mut direction = EXPANDS_UPWARD.lock().map_err(|_| { + Error::PanelError("floating bar placement lock poisoned".to_string()) + })?; + if height > current_size.height { + let monitor = window + .current_monitor()? + .or(window.app_handle().primary_monitor()?) + .ok_or(Error::MonitorNotFound)?; + let work = monitor.work_area(); + let origin = work.position.to_logical::(monitor.scale_factor()); + let size = work.size.to_logical::(monitor.scale_factor()); + *direction = expands_upward( current_position.y, - current_size.width, current_size.height, - width, - height, - ) - } else { - return Ok(()); - }; + origin.y, + size.height, + ); + } + resize_anchored( + previous.0, previous.1, previous.2, previous.3, width, height, *direction, + ) + } else { + return Ok(()); + }; let (clamped_x, clamped_y) = clamp_origin(window, next_x, next_y, width, height)?; + if !force_default_position && height > current_size.height { + *expansion = Some((previous, (clamped_x, clamped_y))); + } + if size_changed { + window.set_size(Size::Logical(next_size))?; + } window.set_position(Position::Logical(LogicalPosition::new( clamped_x, clamped_y, )))?; @@ -424,7 +505,7 @@ mod platform { let work_area = monitor.work_area(); let origin = work_area.position.to_logical::(scale); let size = work_area.size.to_logical::(scale); - Ok(top_right_origin( + Ok(bottom_center_origin( origin.x, origin.y, size.width, @@ -494,6 +575,18 @@ pub fn update_amplitude(amplitude: f64) -> Result<(), Error> { mod tests { use super::layout; + #[test] + fn dragging_away_and_back_does_not_restore_the_old_compact_frame() { + let mut expansion = Some(((10.0, 20.0, 111.0, 67.0), (0.0, 20.0))); + layout::forget_moved_expansion(&mut expansion, (0.0, 20.0)); + assert!(expansion.is_some()); + layout::forget_moved_expansion(&mut expansion, (100.0, 20.0)); + layout::forget_moved_expansion(&mut expansion, (0.0, 20.0)); + assert!(expansion.is_none()); + let current = (0.0, 20.0, 368.0, 459.0); + assert_eq!(layout::collapse_anchor(current, expansion), current); + } + #[test] fn sizes_the_compact_and_expanded_windows() { assert_eq!(layout::container_size(false, false), (84.0, 67.0)); @@ -502,18 +595,67 @@ mod tests { } #[test] - fn pins_the_default_origin_to_the_work_area_top_right() { + fn collapse_restores_position_after_expansion_was_clamped() { + let compact = (1801.0, 1005.0, 111.0, 67.0); + let expanded = layout::resize_anchored( + compact.0, compact.1, compact.2, compact.3, 368.0, 459.0, true, + ); + let clamped = layout::clamp_to_work_area( + expanded.0, expanded.1, 368.0, 459.0, 0.0, 0.0, 1920.0, 1080.0, + ); + let anchor = layout::collapse_anchor( + (clamped.0, clamped.1, 368.0, 459.0), + Some((compact, clamped)), + ); + assert_eq!( + layout::resize_anchored(anchor.0, anchor.1, anchor.2, anchor.3, 111.0, 67.0, true), + (compact.0, compact.1) + ); + let moved = (clamped.0 - 100.0, clamped.1, 368.0, 459.0); + assert_eq!( + layout::collapse_anchor(moved, Some((compact, clamped))), + moved + ); + } + + #[test] + fn pins_the_default_origin_to_the_work_area_bottom_center() { assert_eq!( - layout::top_right_origin(0.0, 0.0, 1920.0, 1080.0, 111.0, 67.0), - (1801.0, 8.0) + layout::bottom_center_origin(0.0, 0.0, 1920.0, 1080.0, 111.0, 67.0), + (904.5, 1005.0) ); } #[test] - fn keeps_the_top_right_anchor_when_resizing() { + fn keeps_the_top_center_anchor_when_expanding_downward() { + assert_eq!( + layout::resize_anchored(1801.0, 8.0, 111.0, 67.0, 368.0, 459.0, false), + (1672.5, 8.0) + ); + } + + #[test] + fn keeps_the_bottom_center_anchor_through_expansion_and_collapse() { + let (x, y) = layout::resize_anchored(904.5, 1005.0, 111.0, 67.0, 368.0, 459.0, true); + assert_eq!((x, y), (776.0, 613.0)); + assert_eq!( + layout::resize_anchored(x, y, 368.0, 459.0, 111.0, 67.0, true), + (904.5, 1005.0) + ); + } + + #[test] + fn chooses_the_side_with_more_space_on_an_offset_monitor() { + assert!(layout::expands_upward(1005.0, 67.0, 40.0, 1040.0)); + assert!(!layout::expands_upward(48.0, 67.0, 40.0, 1040.0)); + assert!(!layout::expands_upward(0.0, 67.0, 0.0, 1080.0)); + } + + #[test] + fn centers_on_an_offset_monitor() { assert_eq!( - layout::resize_keep_top_right(1801.0, 8.0, 111.0, 67.0, 368.0, 459.0), - (1544.0, 8.0) + layout::bottom_center_origin(-1920.0, 40.0, 1920.0, 1040.0, 111.0, 67.0), + (-1015.5, 1005.0) ); } diff --git a/plugins/windows/swift-lib/src/FloatingBarManager.swift b/plugins/windows/swift-lib/src/FloatingBarManager.swift index 41cfaa287b9..e81e342e16c 100644 --- a/plugins/windows/swift-lib/src/FloatingBarManager.swift +++ b/plugins/windows/swift-lib/src/FloatingBarManager.swift @@ -6,6 +6,8 @@ final class FloatingBarManager { static let shared = FloatingBarManager() private var panel: NSPanel? + private var expandsUpward = true + private var expansion: (compact: NSRect, expanded: NSRect)? private let model = FloatingBarViewModel() private let settingsModel = FloatingOverlaySettingsModel.shared private let placement = FloatingPanelPositionController() @@ -71,6 +73,8 @@ final class FloatingBarManager { return } + expandsUpward = true + expansion = nil FloatingBarFonts.register() let panel = createPanel() @@ -81,6 +85,7 @@ final class FloatingBarManager { panelOrigin: { [weak self] in self?.panel?.frame.origin }, movePanel: { [weak self] origin in guard let self, let panel = self.panel else { return } + self.expansion = nil self.placement.moveByUserDrag( panel, to: origin, @@ -191,8 +196,8 @@ final class FloatingBarManager { followsPointer: false ) { screen, size in let frame = screen.visibleFrame - let x = frame.maxX - size.width - FloatingBarLayout.screenMargin - let y = frame.maxY - size.height - FloatingBarLayout.screenMargin + let x = frame.midX - size.width / 2 + let y = frame.minY + FloatingBarLayout.screenMargin return NSPoint(x: x, y: y) } } @@ -207,19 +212,19 @@ final class FloatingBarManager { panel.minSize = size guard previousSize != size else { return false } - let previousLayout = - layout(matching: previousSize) - ?? FloatingBarWindowLayout( - isExpanded: !nextLayout.isExpanded, - showsExpand: model.liveCaptionToggleVisible) - let previousAnchorOffset = controlAnchorOffset(for: previousLayout) + let workArea = (panel.screen ?? NSScreen.main)?.visibleFrame ?? panel.frame + if size.height > previousSize.height { + expandsUpward = FloatingBarPlacement.expandsUpward(frame: panel.frame, workArea: workArea) + } let nextAnchorOffset = controlAnchorOffset(for: nextLayout) - let anchor = placement.anchorPoint(for: panel, offset: previousAnchorOffset) - let frame = NSRect( - x: anchor.x - nextAnchorOffset.x, - y: anchor.y - nextAnchorOffset.y, - width: size.width, - height: size.height) + let frame = FloatingBarPlacement.resizedFrame( + panel.frame, size: size, workArea: workArea, expandsUpward: expandsUpward, + expansion: expansion) + if size.height > previousSize.height { + expansion = (panel.frame, frame) + } else { + expansion = nil + } placement.setFrame( panel, to: frame, @@ -252,35 +257,9 @@ final class FloatingBarManager { ) } - private func layout(matching size: NSSize) -> FloatingBarWindowLayout? { - let candidates = [ - FloatingBarWindowLayout(isExpanded: true, showsExpand: true), - FloatingBarWindowLayout(isExpanded: true, showsExpand: false), - FloatingBarWindowLayout(isExpanded: false, showsExpand: true), - FloatingBarWindowLayout(isExpanded: false, showsExpand: false), - ] - - return candidates.first { candidate in - let candidateSize = self.size(for: candidate) - return abs(candidateSize.width - size.width) < 0.5 - && abs(candidateSize.height - size.height) < 0.5 - } - } - private func controlAnchorOffset(for layout: FloatingBarWindowLayout) -> NSPoint { - if layout.isExpanded { - return NSPoint( - x: FloatingBarLayout.inset + FloatingBarLayout.expandedWidth - - FloatingBarLayout.compactHorizontalPadding, - y: FloatingBarLayout.inset + FloatingBarLayout.expandedHeight - ) - } - - return NSPoint( - x: FloatingBarLayout.inset + FloatingBarLayout.compactHorizontalPadding - + FloatingBarLayout.compactControlsWidth(showsExpand: layout.showsExpand), - y: FloatingBarLayout.inset + FloatingBarLayout.compactHeight - ) + let size = size(for: layout) + return NSPoint(x: size.width / 2, y: expandsUpward ? 0 : size.height) } private func startObservingDisplayChanges() { diff --git a/plugins/windows/swift-lib/src/FloatingBarPlacement.swift b/plugins/windows/swift-lib/src/FloatingBarPlacement.swift new file mode 100644 index 00000000000..84da21ea369 --- /dev/null +++ b/plugins/windows/swift-lib/src/FloatingBarPlacement.swift @@ -0,0 +1,24 @@ +import Cocoa + +enum FloatingBarPlacement { + static func expandsUpward(frame: NSRect, workArea: NSRect) -> Bool { + workArea.maxY - frame.maxY > frame.minY - workArea.minY + } + + static func resizedFrame( + _ frame: NSRect, size: NSSize, workArea: NSRect, expandsUpward: Bool, + expansion: (compact: NSRect, expanded: NSRect)? = nil + ) -> NSRect { + let frame = + expansion.flatMap { saved in + size.height < frame.height && frame == saved.expanded ? saved.compact : nil + } ?? frame + let x = frame.midX - size.width / 2 + let y = expandsUpward ? frame.minY : frame.maxY - size.height + return NSRect( + x: min(max(x, workArea.minX), max(workArea.minX, workArea.maxX - size.width)), + y: min(max(y, workArea.minY), max(workArea.minY, workArea.maxY - size.height)), + width: size.width, + height: size.height) + } +} diff --git a/plugins/windows/swift-lib/tests/FloatingBarPlacementTests.swift b/plugins/windows/swift-lib/tests/FloatingBarPlacementTests.swift new file mode 100644 index 00000000000..57cf8279907 --- /dev/null +++ b/plugins/windows/swift-lib/tests/FloatingBarPlacementTests.swift @@ -0,0 +1,46 @@ +import Cocoa +import XCTest + +@testable import swift_lib + +final class FloatingBarPlacementTests: XCTestCase { + private let workArea = NSRect(x: -1920, y: 40, width: 1920, height: 1040) + private let compactSize = NSSize(width: 111, height: 67) + private let expandedSize = NSSize(width: 368, height: 459) + + func testBottomExpansionAndCollapsePreserveBottomCenter() { + let compact = NSRect(x: -1015.5, y: 48, width: 111, height: 67) + XCTAssertTrue(FloatingBarPlacement.expandsUpward(frame: compact, workArea: workArea)) + let expanded = FloatingBarPlacement.resizedFrame( + compact, size: expandedSize, workArea: workArea, expandsUpward: true) + XCTAssertEqual(expanded.midX, compact.midX) + XCTAssertEqual(expanded.minY, compact.minY) + XCTAssertEqual( + FloatingBarPlacement.resizedFrame( + expanded, size: compactSize, workArea: workArea, expandsUpward: true), compact) + } + + func testTopExpansionAndCollapsePreserveTopCenter() { + let compact = NSRect(x: -1015.5, y: 1005, width: 111, height: 67) + XCTAssertFalse(FloatingBarPlacement.expandsUpward(frame: compact, workArea: workArea)) + let expanded = FloatingBarPlacement.resizedFrame( + compact, size: expandedSize, workArea: workArea, expandsUpward: false) + XCTAssertEqual(expanded.midX, compact.midX) + XCTAssertEqual(expanded.maxY, compact.maxY) + XCTAssertEqual( + FloatingBarPlacement.resizedFrame( + expanded, size: compactSize, workArea: workArea, expandsUpward: false), compact) + } + + func testExpansionClampsAtDisplayEdge() { + let frame = NSRect(x: -112, y: 48, width: 111, height: 67) + let expanded = FloatingBarPlacement.resizedFrame( + frame, size: expandedSize, workArea: workArea, expandsUpward: true) + XCTAssertEqual(expanded.maxX, workArea.maxX) + XCTAssertTrue(workArea.contains(expanded)) + XCTAssertEqual( + FloatingBarPlacement.resizedFrame( + expanded, size: compactSize, workArea: workArea, expandsUpward: true, + expansion: (frame, expanded)), frame) + } +}