diff --git a/.gitignore b/.gitignore
index 0592392..ac77873 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
/target
.DS_Store
+# ignore bin in built app
+assets/macos/RustCast.app/Contents/MacOS/rustcast
diff --git a/Cargo.toml b/Cargo.toml
index ea0f493..1a3d670 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -21,7 +21,7 @@ libc = "0.2.180"
log = "0.4.29"
minreq = { version = "2.14.1", features = ["https"] }
objc2 = "0.6.3"
-objc2-app-kit = { version = "0.3.2", features = ["NSImage"] }
+objc2-app-kit = { version = "0.3.2", features = ["NSImage", "NSScreen"] }
objc2-application-services = { version = "0.3.2", default-features = false, features = ["HIServices", "Processes"] }
objc2-core-foundation = "0.3.2"
objc2-core-graphics = { version = "0.3.2", features = ["CGEvent", "CGEventTypes", "CGRemoteOperation", "CGEventSource", "libc"] }
diff --git a/assets/macos/RustCast.app/Contents/_CodeSignature/CodeResources b/assets/macos/RustCast.app/Contents/_CodeSignature/CodeResources
new file mode 100644
index 0000000..0e2d27e
--- /dev/null
+++ b/assets/macos/RustCast.app/Contents/_CodeSignature/CodeResources
@@ -0,0 +1,150 @@
+
+
+
+
+ files
+
+ Resources/icon.icns
+
+ 6M/cpx7PY0OdSPJTqZaFRWut0sU=
+
+ Resources/icon.png
+
+ cDJSpM8kGx53az6YYfkoZQA7wXc=
+
+ Resources/lemon.png
+
+ BE7OfGooJAzXwW8xgNPIWyh3nDs=
+
+
+ files2
+
+ Resources/icon.icns
+
+ hash2
+
+ UVP08uiw8dyYIE2w18hd9PCcJox0BmkNVkSO2/qKnRo=
+
+
+ Resources/icon.png
+
+ hash2
+
+ phEsB7JUmgrkWy/0M10O7pbf8D2F3gBO7BV0Wo5NAq4=
+
+
+ Resources/lemon.png
+
+ hash2
+
+ bvgew5Cbsqdqva7z76ytvdUA1pJikiUQFKnz1LUKQyA=
+
+
+
+ rules
+
+ ^Resources/
+
+ ^Resources/.*\.lproj/
+
+ optional
+
+ weight
+ 1000
+
+ ^Resources/.*\.lproj/locversion.plist$
+
+ omit
+
+ weight
+ 1100
+
+ ^Resources/Base\.lproj/
+
+ weight
+ 1010
+
+ ^version.plist$
+
+
+ rules2
+
+ .*\.dSYM($|/)
+
+ weight
+ 11
+
+ ^(.*/)?\.DS_Store$
+
+ omit
+
+ weight
+ 2000
+
+ ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/
+
+ nested
+
+ weight
+ 10
+
+ ^.*
+
+ ^Info\.plist$
+
+ omit
+
+ weight
+ 20
+
+ ^PkgInfo$
+
+ omit
+
+ weight
+ 20
+
+ ^Resources/
+
+ weight
+ 20
+
+ ^Resources/.*\.lproj/
+
+ optional
+
+ weight
+ 1000
+
+ ^Resources/.*\.lproj/locversion.plist$
+
+ omit
+
+ weight
+ 1100
+
+ ^Resources/Base\.lproj/
+
+ weight
+ 1010
+
+ ^[^/]+$
+
+ nested
+
+ weight
+ 10
+
+ ^embedded\.provisionprofile$
+
+ weight
+ 20
+
+ ^version\.plist$
+
+ weight
+ 20
+
+
+
+
diff --git a/scripts/local-build.sh b/scripts/local-build.sh
new file mode 100755
index 0000000..08bf4bc
--- /dev/null
+++ b/scripts/local-build.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+
+cargo build --release
+mv target/release/rustcast assets/macos/RustCast.app/Contents/MacOS/rustcast
+rm -rf ~/Applications/Rustcast.app
+cp -r assets/macos/Rustcast.app ~/Applications/Rustcast.app
+codesign --force --deep --sign - ~/Applications/RustCast.app
diff --git a/src/app.rs b/src/app.rs
index 76358a7..b2166ba 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -67,6 +67,7 @@ pub enum ResetField {
DebounceDelay,
StartAtLogin,
AutoUpdate,
+ CheckForUpdates,
HapticFeedback,
ShowMenubarIcon,
ClipboardHistory,
@@ -182,6 +183,7 @@ pub enum SetConfigFields {
SearchUrl(String),
ClipboardHistory(bool),
SetAutoUpdate(bool),
+ SetCheckForUpdates(bool),
HapticFeedback(bool),
ShowMenubarIcon(bool),
SetPage(MainPage),
diff --git a/src/app/apps.rs b/src/app/apps.rs
index 9a17681..2b1539d 100644
--- a/src/app/apps.rs
+++ b/src/app/apps.rs
@@ -177,6 +177,40 @@ impl App {
]
}
+ /// Window tiling actions (12 positions)
+ pub fn window_apps() -> Vec {
+ use crate::platform::macos::window::TilePosition;
+
+ let icons = icns_data_to_handle(ICNS_ICON.to_vec());
+
+ let actions: &[(&str, TilePosition)] = &[
+ ("Left Half", TilePosition::LeftHalf),
+ ("Right Half", TilePosition::RightHalf),
+ ("Top Half", TilePosition::TopHalf),
+ ("Bottom Half", TilePosition::BottomHalf),
+ ("Top Left Quarter", TilePosition::TopLeft),
+ ("Top Right Quarter", TilePosition::TopRight),
+ ("Bottom Left Quarter", TilePosition::BottomLeft),
+ ("Bottom Right Quarter", TilePosition::BottomRight),
+ ("Left Third", TilePosition::LeftThird),
+ ("Center Third", TilePosition::CenterThird),
+ ("Right Third", TilePosition::RightThird),
+ ("Maximize", TilePosition::Maximize),
+ ];
+
+ actions
+ .iter()
+ .map(|(name, pos)| App {
+ ranking: 0,
+ open_command: AppCommand::Function(Function::TileWindow(pos.clone())),
+ desc: "Window Tiling".to_string(),
+ icons: icons.clone(),
+ display_name: name.to_string(),
+ search_name: name.to_lowercase(),
+ })
+ .collect()
+ }
+
/// This renders the app into an iced element, allowing it to be displayed in the search results
pub fn render(
self,
diff --git a/src/app/pages/settings.rs b/src/app/pages/settings.rs
index 58d25d8..4dde0b7 100644
--- a/src/app/pages/settings.rs
+++ b/src/app/pages/settings.rs
@@ -264,6 +264,25 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat
theme.clone(),
);
+ let theme_clone = theme.clone();
+ let check_for_updates = settings_row_with_reset(
+ settings_item_row([
+ settings_hint_text(theme.clone(), "Check for updates"),
+ checkbox(config.clone().check_for_updates)
+ .style(move |_, _| settings_checkbox_style(&theme_clone))
+ .on_toggle(move |input| {
+ Message::SetConfig(SetConfigFields::SetCheckForUpdates(input))
+ })
+ .into(),
+ notice_item(
+ theme.clone(),
+ "If rustcast should check for new releases and show the menubar indicator",
+ ),
+ ]),
+ ResetField::CheckForUpdates,
+ theme.clone(),
+ );
+
let theme_clone = theme.clone();
let haptic = settings_row_with_reset(
Row::from_iter([
@@ -400,6 +419,7 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat
debounce,
start_at_login,
auto_update,
+ check_for_updates,
haptic,
tray_icon,
clipboard_history,
diff --git a/src/app/tile.rs b/src/app/tile.rs
index 73f3d6a..7b748f8 100644
--- a/src/app/tile.rs
+++ b/src/app/tile.rs
@@ -267,7 +267,7 @@ impl Tile {
Subscription::run(crate::platform::macos::urlscheme::url_stream),
Subscription::run(handle_recipient),
Subscription::run(reload_events),
- Subscription::run(handle_version_and_rankings),
+ Subscription::run_with(self.config.check_for_updates, handle_version_and_rankings),
Subscription::run(handle_theme_mode),
Subscription::run(check_event_tap),
Subscription::run(handle_clipboard_history),
@@ -828,10 +828,13 @@ fn handle_theme_mode() -> impl futures::Stream- {
})
}
-fn handle_version_and_rankings() -> impl futures::Stream
- {
- stream::channel(100, async |mut output| {
+fn handle_version_and_rankings(
+ check_for_updates: &bool,
+) -> impl futures::Stream
- + use<> {
+ let check_for_updates = *check_for_updates;
+ stream::channel(100, async move |mut output| {
loop {
- if new_version_available().is_some() {
+ if check_for_updates && new_version_available().is_some() {
output.send(Message::UpdateAvailable).await.ok();
}
tokio::time::sleep(Duration::from_secs(30)).await;
diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs
index 5bc0fa5..d9dcef4 100644
--- a/src/app/tile/elm.rs
+++ b/src/app/tile/elm.rs
@@ -59,6 +59,8 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) {
options.extend(App::basic_apps());
info!("Loaded basic apps / default apps");
+ options.extend(App::window_apps());
+ info!("Loaded window tiling apps");
options.par_sort_by_key(|x| x.display_name.len());
let options = AppIndex::from_apps(options);
diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs
index 01283ba..81038e8 100644
--- a/src/app/tile/update.rs
+++ b/src/app/tile/update.rs
@@ -193,7 +193,13 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task {
std::process::exit(1);
}
};
- if tile.config.show_trayicon {
+ if let Some(ref mut icon) = tile.tray_icon {
+ icon.set_menu(Some(Box::new(menu_builder(
+ tile.config.clone(),
+ sender,
+ tile.update_available,
+ ))));
+ } else if tile.config.show_trayicon {
tile.tray_icon = Some(menu_icon(tile.config.clone(), sender));
}
Task::none()
@@ -532,6 +538,14 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task {
}
Message::RunFunction(command) => {
+ if let Function::TileWindow(pos) = &command {
+ if let Some(pid) = tile.frontmost.as_ref().map(|a| a.processIdentifier()) {
+ let ok = crate::platform::macos::window::tile_focused_window(pid, pos);
+ if !ok && tile.config.haptic_feedback {
+ perform_haptic(HapticPattern::Alignment);
+ }
+ }
+ }
command.execute(&tile.config);
let page_task = match tile.page {
Page::Settings => Task::done(Message::SwitchToPage(Page::Main)),
@@ -632,6 +646,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task {
new_options.extend(tile.config.shells.iter().map(|x| x.to_app()));
new_options.extend(tile.config.modes.to_apps());
new_options.extend(App::basic_apps());
+ new_options.extend(App::window_apps());
new_options.par_sort_by_key(|x| x.display_name.len());
tile.options = AppIndex::from_apps(new_options);
@@ -959,6 +974,9 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task {
SetConfigFields::SetAutoUpdate(au) => {
final_config.auto_update = au;
}
+ SetConfigFields::SetCheckForUpdates(check) => {
+ final_config.check_for_updates = check;
+ }
SetConfigFields::ShowMenubarIcon(show) => final_config.show_trayicon = show,
SetConfigFields::SetThemeFields(SetConfigThemeFields::Font(fnt)) => {
final_config.theme.font = Some(fnt)
@@ -1013,6 +1031,9 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task {
ResetField::DebounceDelay => tile.config.debounce_delay = default.debounce_delay,
ResetField::StartAtLogin => tile.config.start_at_login = default.start_at_login,
ResetField::AutoUpdate => tile.config.auto_update = default.auto_update,
+ ResetField::CheckForUpdates => {
+ tile.config.check_for_updates = default.check_for_updates;
+ }
ResetField::HapticFeedback => tile.config.haptic_feedback = default.haptic_feedback,
ResetField::ShowMenubarIcon => tile.config.show_trayicon = default.show_trayicon,
ResetField::ClipboardHistory => tile.config.cbhist = default.cbhist,
diff --git a/src/commands.rs b/src/commands.rs
index fd21ebb..c4f88e5 100644
--- a/src/commands.rs
+++ b/src/commands.rs
@@ -28,6 +28,7 @@ pub enum Function {
GoogleSearch(String),
Calculate(Expr),
Quit,
+ TileWindow(crate::platform::macos::window::TilePosition),
}
impl Function {
@@ -122,6 +123,10 @@ impl Function {
},
Function::Quit => std::process::exit(0),
+
+ // TileWindow is intercepted in the RunFunction handler which has
+ // access to the frontmost PID; nothing to do here.
+ Function::TileWindow(_) => {}
}
}
}
diff --git a/src/config.rs b/src/config.rs
index d255806..bc8f7fe 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -37,6 +37,7 @@ pub struct Config {
pub log_path: String,
pub debounce_delay: u64,
pub auto_update: bool,
+ pub check_for_updates: bool,
}
impl Default for Config {
@@ -55,6 +56,7 @@ impl Default for Config {
cbhist_paste_on_select: false,
haptic_feedback: false,
auto_update: true,
+ check_for_updates: true,
show_trayicon: true,
main_page: MainPage::default(),
search_dirs: vec!["~".to_string()],
diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs
index ef6a8ed..9eeca17 100644
--- a/src/platform/macos/mod.rs
+++ b/src/platform/macos/mod.rs
@@ -5,6 +5,7 @@ pub mod events;
pub mod haptics;
pub mod launching;
pub mod urlscheme;
+pub mod window;
use iced::wgpu::rwh::WindowHandle;
diff --git a/src/platform/macos/window.rs b/src/platform/macos/window.rs
new file mode 100644
index 0000000..8cccd7e
--- /dev/null
+++ b/src/platform/macos/window.rs
@@ -0,0 +1,289 @@
+use std::ffi::c_void;
+
+use libc::pid_t;
+use objc2::MainThreadMarker;
+use objc2_app_kit::NSScreen;
+use objc2_foundation::ns_string;
+
+type AXUIElementRef = *mut c_void;
+type AXValueRef = *mut c_void;
+type CFTypeRef = *const c_void;
+type AXError = i32;
+
+const K_AX_SUCCESS: AXError = 0;
+const K_AX_VALUE_CGPOINT: u32 = 1;
+const K_AX_VALUE_CGSIZE: u32 = 2;
+
+#[repr(C)]
+#[derive(Clone, Copy)]
+struct RawPoint {
+ x: f64,
+ y: f64,
+}
+
+#[repr(C)]
+#[derive(Clone, Copy)]
+struct RawSize {
+ width: f64,
+ height: f64,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum TilePosition {
+ LeftHalf,
+ RightHalf,
+ TopHalf,
+ BottomHalf,
+ TopLeft,
+ TopRight,
+ BottomLeft,
+ BottomRight,
+ LeftThird,
+ CenterThird,
+ RightThird,
+ Maximize,
+}
+
+#[link(name = "ApplicationServices", kind = "framework")]
+unsafe extern "C" {
+ fn AXUIElementCreateApplication(pid: pid_t) -> AXUIElementRef;
+ fn AXUIElementCopyAttributeValue(
+ element: AXUIElementRef,
+ attr: *const c_void,
+ out: *mut CFTypeRef,
+ ) -> AXError;
+ fn AXUIElementSetAttributeValue(
+ element: AXUIElementRef,
+ attr: *const c_void,
+ value: CFTypeRef,
+ ) -> AXError;
+ fn AXValueCreate(ty: u32, value: *const c_void) -> AXValueRef;
+ fn AXValueGetValue(v: AXValueRef, ty: u32, out: *mut c_void) -> bool;
+}
+
+#[allow(clashing_extern_declarations)]
+#[link(name = "CoreFoundation", kind = "framework")]
+unsafe extern "C" {
+ fn CFRelease(cf: CFTypeRef);
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Rect {
+ pub x: f64,
+ pub y: f64,
+ pub w: f64,
+ pub h: f64,
+}
+
+pub fn rect_for(pos: &TilePosition, vf: Rect) -> Rect {
+ let hw = vf.w / 2.0;
+ let hh = vf.h / 2.0;
+ let tw = vf.w / 3.0;
+ match pos {
+ TilePosition::LeftHalf => Rect { x: vf.x, y: vf.y, w: hw, h: vf.h },
+ TilePosition::RightHalf => Rect { x: vf.x + hw, y: vf.y, w: hw, h: vf.h },
+ // In Cocoa coords +y is up, so "top" half lives at higher y
+ TilePosition::TopHalf => Rect { x: vf.x, y: vf.y + hh, w: vf.w, h: hh },
+ TilePosition::BottomHalf => Rect { x: vf.x, y: vf.y, w: vf.w, h: hh },
+ TilePosition::TopLeft => Rect { x: vf.x, y: vf.y + hh, w: hw, h: hh },
+ TilePosition::TopRight => Rect { x: vf.x + hw, y: vf.y + hh, w: hw, h: hh },
+ TilePosition::BottomLeft => Rect { x: vf.x, y: vf.y, w: hw, h: hh },
+ TilePosition::BottomRight => Rect { x: vf.x + hw, y: vf.y, w: hw, h: hh },
+ TilePosition::LeftThird => Rect { x: vf.x, y: vf.y, w: tw, h: vf.h },
+ TilePosition::CenterThird => Rect { x: vf.x + tw, y: vf.y, w: tw, h: vf.h },
+ TilePosition::RightThird => Rect { x: vf.x + tw * 2.0, y: vf.y, w: tw, h: vf.h },
+ TilePosition::Maximize => vf,
+ }
+}
+
+/// Tile the focused window of `pid` to `pos`. Returns false on hard failure.
+/// Must be called from the main thread.
+pub fn tile_focused_window(pid: pid_t, pos: &TilePosition) -> bool {
+ // kAXFocusedWindowAttribute etc. are #define CFSTR(...) macros, not linked symbols.
+ // Cast &NSString to *const c_void — toll-free bridged with CFStringRef.
+ let attr_focused_win = ns_string!("AXFocusedWindow") as *const _ as *const c_void;
+ let attr_position = ns_string!("AXPosition") as *const _ as *const c_void;
+ let attr_size = ns_string!("AXSize") as *const _ as *const c_void;
+
+ unsafe {
+ let app_elem = AXUIElementCreateApplication(pid);
+ if app_elem.is_null() {
+ return false;
+ }
+
+ // Get the focused window element
+ let mut win_ref: CFTypeRef = std::ptr::null();
+ let err = AXUIElementCopyAttributeValue(app_elem, attr_focused_win, &mut win_ref);
+ CFRelease(app_elem as CFTypeRef);
+ if err != K_AX_SUCCESS || win_ref.is_null() {
+ return false;
+ }
+ let win = win_ref as AXUIElementRef;
+
+ // Read current window position (AX coords: top-left origin, y downward)
+ let mut pos_ref: CFTypeRef = std::ptr::null();
+ if AXUIElementCopyAttributeValue(win, attr_position, &mut pos_ref) != K_AX_SUCCESS
+ || pos_ref.is_null()
+ {
+ CFRelease(win as CFTypeRef);
+ return false;
+ }
+ let mut raw_pos = RawPoint { x: 0.0, y: 0.0 };
+ AXValueGetValue(
+ pos_ref as AXValueRef,
+ K_AX_VALUE_CGPOINT,
+ &mut raw_pos as *mut RawPoint as *mut c_void,
+ );
+ CFRelease(pos_ref);
+
+ // Read current window size for center calculation
+ let mut sz_ref: CFTypeRef = std::ptr::null();
+ let mut raw_sz = RawSize { width: 0.0, height: 0.0 };
+ if AXUIElementCopyAttributeValue(win, attr_size, &mut sz_ref) == K_AX_SUCCESS
+ && !sz_ref.is_null()
+ {
+ AXValueGetValue(
+ sz_ref as AXValueRef,
+ K_AX_VALUE_CGSIZE,
+ &mut raw_sz as *mut RawSize as *mut c_void,
+ );
+ CFRelease(sz_ref);
+ }
+
+ // Window center in AX coords
+ let cx = raw_pos.x + raw_sz.width / 2.0;
+ let cy_ax = raw_pos.y + raw_sz.height / 2.0;
+
+ // Find the target screen via NSScreen (main thread required)
+ let mtm = MainThreadMarker::new().expect("must be on main thread");
+ let screens = NSScreen::screens(mtm);
+ let count = screens.len();
+
+ // Primary screen height for AX ↔ Cocoa coordinate flip
+ // AX y = primary_h - (cocoa_y + h); Cocoa y = primary_h - ax_y - h
+ // Safety: NSScreen array is not mutated during this function call
+ let primary_h = if count > 0 {
+ screens.objectAtIndex_unchecked(0).frame().size.height
+ } else {
+ 768.0
+ };
+
+ // Convert AX window center to Cocoa coords (bottom-left origin, y upward)
+ let cy_cocoa = primary_h - cy_ax;
+
+ let mut target_vf = None;
+ for i in 0..count {
+ let s = screens.objectAtIndex_unchecked(i);
+ let f = s.frame();
+ if cx >= f.origin.x
+ && cx < f.origin.x + f.size.width
+ && cy_cocoa >= f.origin.y
+ && cy_cocoa < f.origin.y + f.size.height
+ {
+ target_vf = Some(s.visibleFrame());
+ break;
+ }
+ }
+ // Fall back to primary screen
+ if target_vf.is_none() && count > 0 {
+ target_vf = Some(screens.objectAtIndex_unchecked(0).visibleFrame());
+ }
+
+ let vf_ns = match target_vf {
+ Some(r) => r,
+ None => {
+ CFRelease(win as CFTypeRef);
+ return false;
+ }
+ };
+
+ let vf = Rect {
+ x: vf_ns.origin.x,
+ y: vf_ns.origin.y,
+ w: vf_ns.size.width,
+ h: vf_ns.size.height,
+ };
+
+ let target = rect_for(pos, vf);
+
+ // Flip target Cocoa rect to AX coords
+ let ax_y = primary_h - (target.y + target.h);
+ let new_pos = RawPoint { x: target.x, y: ax_y };
+ let new_sz = RawSize { width: target.w, height: target.h };
+
+ let sz_val = AXValueCreate(
+ K_AX_VALUE_CGSIZE,
+ &new_sz as *const RawSize as *const c_void,
+ );
+ let pos_val = AXValueCreate(
+ K_AX_VALUE_CGPOINT,
+ &new_pos as *const RawPoint as *const c_void,
+ );
+
+ // Set size → position → size (double-set defeats per-app min-size clamping)
+ AXUIElementSetAttributeValue(win, attr_size, sz_val as CFTypeRef);
+ AXUIElementSetAttributeValue(win, attr_position, pos_val as CFTypeRef);
+ AXUIElementSetAttributeValue(win, attr_size, sz_val as CFTypeRef);
+
+ CFRelease(sz_val as CFTypeRef);
+ CFRelease(pos_val as CFTypeRef);
+ CFRelease(win as CFTypeRef);
+
+ true
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const VF: Rect = Rect {
+ x: 0.0,
+ y: 23.0,
+ w: 1920.0,
+ h: 1057.0,
+ };
+
+ #[test]
+ fn halves_cover_full_area() {
+ let l = rect_for(&TilePosition::LeftHalf, VF);
+ let r = rect_for(&TilePosition::RightHalf, VF);
+ assert!((l.w + r.w - VF.w).abs() < 0.001);
+ assert_eq!(l.x, VF.x);
+ assert!((l.x + l.w - r.x).abs() < 0.001);
+
+ let t = rect_for(&TilePosition::TopHalf, VF);
+ let b = rect_for(&TilePosition::BottomHalf, VF);
+ assert!((t.h + b.h - VF.h).abs() < 0.001);
+ assert!((b.y + b.h - t.y).abs() < 0.001);
+ }
+
+ #[test]
+ fn quarters_tile_without_overlap() {
+ let tl = rect_for(&TilePosition::TopLeft, VF);
+ let tr = rect_for(&TilePosition::TopRight, VF);
+ let bl = rect_for(&TilePosition::BottomLeft, VF);
+ let br = rect_for(&TilePosition::BottomRight, VF);
+ assert!((tl.w + tr.w - VF.w).abs() < 0.001);
+ assert!((tl.h + bl.h - VF.h).abs() < 0.001);
+ assert!((tl.x + tl.w - tr.x).abs() < 0.001);
+ assert!((bl.x + bl.w - br.x).abs() < 0.001);
+ // top row sits above bottom row
+ assert!((tl.y - (bl.y + bl.h)).abs() < 0.001);
+ }
+
+ #[test]
+ fn thirds_split_width_into_3() {
+ let l = rect_for(&TilePosition::LeftThird, VF);
+ let c = rect_for(&TilePosition::CenterThird, VF);
+ let r = rect_for(&TilePosition::RightThird, VF);
+ assert!((l.w + c.w + r.w - VF.w).abs() < 0.001);
+ assert!((l.x + l.w - c.x).abs() < 0.001);
+ assert!((c.x + c.w - r.x).abs() < 0.001);
+ }
+
+ #[test]
+ fn maximize_equals_visible_frame() {
+ assert_eq!(rect_for(&TilePosition::Maximize, VF), VF);
+ }
+}