use super::*;
use std::path::PathBuf;
use std::process::{Child, Command};
use std::sync::Arc;
use std::time::Instant;
use windows::Win32::Graphics::Gdi::{
EnumDisplayMonitors, GetObjectW, GetPixel, GetTextExtentPoint32W, HDC, HMONITOR, LOGFONTW,
};
use windows::Win32::UI::HiDpi::{
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, SetProcessDpiAwarenessContext,
};
use windows::Win32::UI::Input::KeyboardAndMouse::{
INPUT, INPUT_0, INPUT_MOUSE, MOUSE_EVENT_FLAGS, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP,
MOUSEINPUT, SendInput,
};
use windows::Win32::UI::WindowsAndMessaging::{
DispatchMessageW, EnumWindows, GA_ROOT, GUITHREADINFO, GW_HWNDPREV, GWL_EXSTYLE, GetAncestor,
GetClassNameW, GetForegroundWindow, GetGUIThreadInfo, GetLayeredWindowAttributes, GetWindow,
GetWindowLongPtrW, GetWindowTextW, GetWindowThreadProcessId, IsWindow, MSG, PM_REMOVE,
PeekMessageW, PostMessageW, SW_SHOWNOACTIVATE, SWP_SHOWWINDOW, SetCursorPos, ShowWindow,
TranslateMessage, WM_CLOSE, WS_BORDER, WindowFromPoint,
};
use windows::core::{BOOL, w};
#[test]
fn utf16_fit_counts_whole_chars() {
assert_eq!(chars_within_utf16("abc", 2), 2);
assert_eq!(chars_within_utf16("aあb", 2), 2);
assert_eq!(chars_within_utf16("abc", 0), 0);
assert_eq!(chars_within_utf16("abc", 10), 3);
}
#[test]
fn utf16_fit_does_not_split_surrogate_pair() {
let s = "a😀b";
assert_eq!(chars_within_utf16(s, 1), 1);
assert_eq!(chars_within_utf16(s, 2), 1, "対の途中では切らない");
assert_eq!(chars_within_utf16(s, 3), 2);
assert_eq!(chars_within_utf16(s, 4), 3);
}
fn per_monitor_aware() {
let _ = unsafe { SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) };
}
fn pump_until(timeout: Duration, mut cond: impl FnMut() -> bool) -> bool {
let end = Instant::now() + timeout;
loop {
let mut msg = MSG::default();
unsafe {
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
if cond() {
return true;
}
if Instant::now() >= end {
return false;
}
std::thread::sleep(Duration::from_millis(10));
}
}
fn pump_for(d: Duration) {
pump_until(d, || false);
}
fn size_of_rect(r: RectPx) -> SizePx {
SizePx {
width: r.right - r.left,
height: r.bottom - r.top,
}
}
fn center(r: RectPx) -> PointPx {
PointPx {
x: r.left + (r.right - r.left) / 2,
y: r.top + (r.bottom - r.top) / 2,
}
}
fn hwnd_rect(hwnd: HWND) -> RectPx {
window_rect(hwnd).expect("窓の矩形")
}
fn primary() -> (RectPx, u32) {
monitor_at(PointPx { x: 0, y: 0 }).expect("主画面の情報")
}
fn monitor_rects() -> Vec<RECT> {
unsafe extern "system" fn visit(_: HMONITOR, _: HDC, rc: *mut RECT, data: LPARAM) -> BOOL {
unsafe { (*(data.0 as *mut Vec<RECT>)).push(*rc) };
BOOL(1)
}
let mut rects: Vec<RECT> = Vec::new();
let _ =
unsafe { EnumDisplayMonitors(None, None, Some(visit), LPARAM((&raw mut rects) as isize)) };
rects
}
fn expected_top_left(anchor: PointPx, tip: SizePx) -> PointPx {
let (work_area, dpi) = monitor_at(anchor).expect("画面の情報");
let offset_below = unsafe { GetSystemMetricsForDpi(SM_CYCURSOR, dpi) };
place(&PlaceInput {
anchor,
tip,
work_area,
offset_above: scale(20, dpi),
offset_below,
})
}
fn foreground_and_focus() -> (HWND, HWND) {
unsafe {
let fg = GetForegroundWindow();
let tid = GetWindowThreadProcessId(fg, None);
let mut gti = GUITHREADINFO {
cbSize: size_of::<GUITHREADINFO>() as u32,
..Default::default()
};
let _ = GetGUIThreadInfo(tid, &mut gti);
(fg, gti.hwndFocus)
}
}
fn assert_desktop_in_use(foreground: HWND) {
assert!(
!foreground.is_invalid(),
"前提: 手前の窓がある(スクリーンセーバーや錠の画面の間は確かめられない。画面を使える状態で走らせ直す)"
);
}
fn is_above(upper: HWND, lower: HWND) -> bool {
let mut h = lower;
while let Ok(prev) = unsafe { GetWindow(h, GW_HWNDPREV) } {
if prev == upper {
return true;
}
h = prev;
}
false
}
fn marker_window(r: RectPx, ex_noactivate: bool) -> HWND {
const SS_NOTIFY: u32 = 0x0100;
let mut ex = WS_EX_TOPMOST | WS_EX_TOOLWINDOW;
if ex_noactivate {
ex |= WS_EX_NOACTIVATE;
}
unsafe {
let hwnd = CreateWindowExW(
ex,
w!("STATIC"),
w!("tooltip os_tests"),
WINDOW_STYLE(WS_POPUP.0 | WS_BORDER.0 | SS_NOTIFY),
r.left,
r.top,
r.right - r.left,
r.bottom - r.top,
None,
None,
None,
None,
)
.expect("印の窓");
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
hwnd
}
}
fn click_at(pt: PointPx) {
let button = |flags: MOUSE_EVENT_FLAGS| INPUT {
r#type: INPUT_MOUSE,
Anonymous: INPUT_0 {
mi: MOUSEINPUT {
dwFlags: flags,
..Default::default()
},
},
};
unsafe {
SetCursorPos(pt.x, pt.y).expect("カーソルを動かす");
let sent = SendInput(
&[button(MOUSEEVENTF_LEFTDOWN), button(MOUSEEVENTF_LEFTUP)],
size_of::<INPUT>() as i32,
);
assert_eq!(sent, 2, "押す・離すの入力が送れた");
}
}
struct Watchdog(Arc<AtomicBool>);
impl Watchdog {
fn start(name: &'static str, limit: Duration) -> Self {
let done = Arc::new(AtomicBool::new(false));
let seen = done.clone();
std::thread::spawn(move || {
let end = Instant::now() + limit;
while Instant::now() < end {
if seen.load(Ordering::Relaxed) {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
eprintln!("[os_tests] {name} が {limit:?} で終わらないので止める");
std::process::abort();
});
Self(done)
}
}
impl Drop for Watchdog {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
#[test]
#[ignore = "実機の画面とマウスを使う(--ignored で明示して走らせる)"]
fn s2_given_position_is_top_left_and_size_is_known_before_placing() {
per_monitor_aware();
s2_check(&mut TipWindow::default());
}
fn s2_check(tip: &mut TipWindow) {
let (work, dpi) = primary();
let anchor = center(work);
let cases = [
(
"最初の試し S2
ふたつめの行",
anchor,
false,
),
("出ているまま文字を長くする(その場で測る)", anchor, true),
(
"消した後に別の所へ",
PointPx {
x: work.left + 300,
y: work.bottom - 300,
},
false,
),
];
for (i, &(text, anchor, _)) in cases.iter().enumerate() {
let rect = tip.show(text, anchor).expect("出す");
pump_for(Duration::from_millis(150));
let hwnd = tip.hwnd.expect("窓");
assert!(is_visible(hwnd), "出ている");
assert_eq!(hwnd_rect(hwnd), rect, "返した矩形は出ている窓の矩形");
let want = expected_top_left(anchor, size_of_rect(rect));
tip.hide();
let parked = set_text_and_measure(hwnd, &normalize_newlines(text)).expect("測り直す");
println!(
"[S2] dpi={dpi} anchor={anchor:?} rect={rect:?} want={want:?} 画面の外で測った={parked:?}"
);
assert_eq!(
(rect.left, rect.top),
(want.x, want.y),
"測った大きさで置いた(渡した位置が左上)"
);
assert_eq!(parked, size_of_rect(rect), "画面の外で測った大きさで出た");
if cases
.get(i + 1)
.is_some_and(|&(_, _, keep_shown)| keep_shown)
{
assert_eq!(
tip.show(text, anchor).expect("出し直す"),
rect,
"同じ所に出る"
);
}
}
tip.hide();
let hwnd = tip.hwnd.expect("窓");
assert!(!is_visible(hwnd), "消える");
set_text_and_measure(hwnd, "画面の外で測る").expect("測る");
let parked = hwnd_rect(hwnd);
println!("[S2] 出ていないときに測った位置={parked:?}");
assert_eq!(
(parked.left, parked.top),
(PARKED.x, PARKED.y),
"画面の外に置いて測った"
);
tip.hide();
}
#[test]
#[ignore = "実機の画面とマウスを使う(--ignored で明示して走らせる)"]
fn negative_coordinate_screen_keeps_given_top_left() {
per_monitor_aware();
let Some(m) = monitor_rects()
.into_iter()
.find(|r| r.left < 0 || r.top < 0)
else {
println!("[負の座標] 主画面の左・上に画面が無いので確かめられない(not available)");
return;
};
let mut tip = TipWindow::default();
let anchor = PointPx {
x: m.left + 200,
y: m.top + 200,
};
let rect = tip.show("負の座標の画面", anchor).expect("出す");
pump_for(Duration::from_millis(200));
let want = expected_top_left(anchor, size_of_rect(rect));
let (work, _) = monitor_at(anchor).expect("画面の情報");
println!("[負の座標] monitor={m:?} anchor={anchor:?} rect={rect:?} want={want:?}");
assert_eq!(
(rect.left, rect.top),
(want.x, want.y),
"負の座標でも渡した位置が左上"
);
assert!(
rect.left >= work.left && rect.right <= work.right,
"その画面の作業領域の中"
);
tip.hide();
}
#[test]
#[ignore = "実機の画面とマウスを使う(--ignored で明示して走らせる)"]
fn s3_showing_keeps_foreground_and_focus() {
per_monitor_aware();
let mut tip = TipWindow::default();
let (work, _) = primary();
let before = foreground_and_focus();
assert_desktop_in_use(before.0);
let rect = tip.show("最初の試し S3", center(work)).expect("出す");
pump_for(Duration::from_millis(300));
let after_first = foreground_and_focus();
tip.show("最初の試し S3(出し直し)", center(work))
.expect("出し直す");
pump_for(Duration::from_millis(300));
let after_second = foreground_and_focus();
let hwnd = tip.hwnd.expect("窓");
println!(
"[S3] tip={hwnd:?} rect={rect:?} before={before:?} after={after_first:?} again={after_second:?}"
);
assert_eq!(after_first, before, "出しても手前の窓と入力先が変わらない");
assert_eq!(after_second, before, "出し直しても変わらない");
assert_ne!(before.0, hwnd, "ツールチップが手前の窓になっていない");
tip.hide();
}
struct Notepad {
child: Child,
hwnd: HWND,
file: PathBuf,
}
impl Notepad {
fn open() -> Self {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/tooltip_os_tests");
std::fs::create_dir_all(&dir).expect("target の下の作業場所");
let stem = format!("tooltip-s4-{}", std::process::id());
let file = dir.join(format!("{stem}.txt"));
std::fs::write(&file, "").expect("空のファイル");
let existing = top_windows();
let child = Command::new("notepad.exe")
.arg(&file)
.spawn()
.expect("メモ帳を起こす");
let mut found = None;
let appeared = pump_until(Duration::from_secs(10), || {
found = top_windows()
.into_iter()
.find(|(h, class, title)| {
!existing.iter().any(|(e, ..)| e == h)
&& class == "Notepad"
&& title.contains(&stem)
})
.map(|(h, ..)| h);
found.is_some()
});
let Some(hwnd) = found.filter(|_| appeared) else {
let mut child = child;
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_file(&file);
panic!(
"メモ帳が新しい窓で開かなかった(既存の窓のタブに入った可能性。既存の窓には触れない)"
);
};
Self { child, hwnd, file }
}
}
impl Drop for Notepad {
fn drop(&mut self) {
let _ = unsafe { PostMessageW(Some(self.hwnd), WM_CLOSE, WPARAM(0), LPARAM(0)) };
let closed = pump_until(Duration::from_secs(5), || unsafe {
!IsWindow(Some(self.hwnd)).as_bool()
});
if !closed {
eprintln!("[S4] メモ帳の窓が閉じない(確認の問いが出ている可能性。手で閉じる)");
}
if let Ok(None) = self.child.try_wait() {
let _ = self.child.kill();
}
let _ = self.child.wait();
let _ = std::fs::remove_file(&self.file);
}
}
fn top_windows() -> Vec<(HWND, String, String)> {
unsafe extern "system" fn visit(h: HWND, data: LPARAM) -> BOOL {
unsafe { (*(data.0 as *mut Vec<HWND>)).push(h) };
BOOL(1)
}
let mut all: Vec<HWND> = Vec::new();
let _ = unsafe { EnumWindows(Some(visit), LPARAM((&raw mut all) as isize)) };
all.into_iter()
.filter(|h| is_visible(*h))
.map(|h| {
let (mut class, mut title) = ([0u16; 64], [0u16; 260]);
let (c, t) = unsafe { (GetClassNameW(h, &mut class), GetWindowTextW(h, &mut title)) };
(
h,
String::from_utf16_lossy(&class[..c.max(0) as usize]),
String::from_utf16_lossy(&title[..t.max(0) as usize]),
)
})
.collect()
}
fn screen_colours(r: RectPx) -> Vec<u32> {
let mut out = Vec::new();
unsafe {
let hdc = GetDC(None);
for row in 0..4 {
for col in 0..8 {
let x = r.left + 3 + (r.right - r.left - 6) * col / 7;
let y = r.top + 3 + (r.bottom - r.top - 6) * row / 3;
out.push(GetPixel(hdc, x, y).0);
}
}
ReleaseDC(None, hdc);
}
out
}
fn root_at(pt: PointPx) -> HWND {
unsafe { GetAncestor(WindowFromPoint(POINT { x: pt.x, y: pt.y }), GA_ROOT) }
}
#[test]
#[ignore = "実機の画面とマウスを使う(--ignored で明示して走らせる)"]
fn s4_press_on_tooltip_reaches_notepad_beneath_and_hover_does_not_recurse() {
per_monitor_aware();
let _watch = Watchdog::start("S4", Duration::from_secs(60));
let mut home = POINT::default();
let _ = unsafe { GetCursorPos(&mut home) };
let (work, _) = primary();
assert_desktop_in_use(unsafe { GetForegroundWindow() });
let notepad = Notepad::open();
let mut pid = 0u32;
unsafe { GetWindowThreadProcessId(notepad.hwnd, Some(&mut pid)) };
unsafe {
SetWindowPos(
notepad.hwnd,
Some(windows::Win32::UI::WindowsAndMessaging::HWND_TOP),
work.left + 40,
work.top + 40,
((work.right - work.left) / 2).max(600),
((work.bottom - work.top) * 2 / 3).max(400),
SWP_NOACTIVATE | SWP_SHOWWINDOW,
)
}
.expect("メモ帳を動かす");
pump_for(Duration::from_millis(500));
let pad = hwnd_rect(notepad.hwnd);
let marker = marker_window(
RectPx {
left: work.right - 260,
top: work.top + 60,
right: work.right - 60,
bottom: work.top + 180,
},
false,
);
let marker_rect = hwnd_rect(marker);
assert!(
marker_rect.left >= pad.right,
"印の窓とメモ帳が重ならない(主画面が狭すぎる): pad={pad:?} marker={marker_rect:?}"
);
pump_for(Duration::from_millis(200));
click_at(center(marker_rect));
pump_until(Duration::from_secs(2), || unsafe {
GetForegroundWindow() == marker
});
let fg_before = unsafe { GetForegroundWindow() };
assert_ne!(
fg_before, notepad.hwnd,
"前提: 押す前はメモ帳が手前の窓ではない"
);
let anchor = PointPx {
x: pad.left + (pad.right - pad.left) / 2,
y: pad.top + (pad.bottom - pad.top) * 2 / 3,
};
let text = "最初の試し S4(この上を押すと下のメモ帳へ届く)";
let mut tip = TipWindow::default();
let rect = tip.show(text, anchor).expect("出す");
let hwnd = tip.hwnd.expect("窓");
let press = center(rect);
tip.hide();
pump_for(Duration::from_millis(100));
assert_eq!(root_at(press), notepad.hwnd, "前提: 押す点の下はメモ帳");
let hidden = screen_colours(rect);
let rect_again = tip.show(text, anchor).expect("出し直す");
assert_eq!(rect_again, rect);
pump_for(Duration::from_millis(200));
let ex = unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) } as u32;
assert!(
ex & WS_EX_LAYERED.0 != 0,
"出した後も層の窓のまま: ex={ex:#x}"
);
let (mut alpha, mut flags) = (0u8, Default::default());
unsafe { GetLayeredWindowAttributes(hwnd, None, Some(&mut alpha), Some(&mut flags)) }
.expect("層の設定を読む");
assert!(
alpha == 255 && flags.contains(LWA_ALPHA),
"不透明の層の窓: alpha={alpha} flags={flags:?}"
);
let shown = screen_colours(rect);
let distinct = |v: &[u32]| v.iter().collect::<std::collections::HashSet<_>>().len();
println!(
"[S4] 色の数 隠した時={} 出した時={}",
distinct(&hidden),
distinct(&shown)
);
assert!(
shown.iter().any(|c| !hidden.contains(c)),
"ツールチップが画面に映った(隠した時に無い色がある): hidden={hidden:x?} shown={shown:x?}"
);
let from_point = root_at(press);
for i in 0..8 {
let p = PointPx {
x: rect.left + 2 + (rect.right - rect.left - 4) * i / 7,
y: press.y,
};
unsafe { SetCursorPos(p.x, p.y) }.expect("カーソルを動かす");
pump_for(Duration::from_millis(40));
}
assert!(is_visible(hwnd), "上でマウスを動かしても出たまま");
click_at(press);
let reached = pump_until(Duration::from_secs(3), || unsafe {
GetForegroundWindow() == notepad.hwnd
});
let fg_after = unsafe { GetForegroundWindow() };
println!(
"[S4] notepad={:?} pid={pid} pad={pad:?} tip={hwnd:?} rect={rect:?} press={press:?} \
WindowFromPoint の最上位={from_point:?} before={fg_before:?} after={fg_after:?} reached={reached}",
notepad.hwnd
);
tip.hide();
unsafe {
let _ = DestroyWindow(marker);
let _ = SetCursorPos(home.x, home.y);
}
drop(notepad);
assert!(
reached,
"ツールチップの上の押下が下のメモ帳へ届き、メモ帳が手前の窓になる"
);
}
fn text_height(hwnd: HWND, font: HFONT, text: &str) -> i32 {
let wide: Vec<u16> = text.encode_utf16().collect();
let mut size = SIZE::default();
unsafe {
let hdc = GetDC(Some(hwnd));
let old = SelectObject(hdc, font.into());
let _ = GetTextExtentPoint32W(hdc, &wide, &mut size);
SelectObject(hdc, old);
ReleaseDC(Some(hwnd), hdc);
}
size.cy
}
fn em_height(font: HFONT) -> i32 {
let mut lf = LOGFONTW::default();
unsafe {
GetObjectW(
font.into(),
size_of::<LOGFONTW>() as i32,
Some((&raw mut lf).cast()),
)
};
lf.lfHeight.abs()
}
#[test]
#[ignore = "実機の画面とマウスを使う(--ignored で明示して走らせる)"]
fn s6_font_follows_each_screen_dpi() {
per_monitor_aware();
let mut tip = TipWindow::default();
let mut seen: Vec<(u32, i32, i32, i32)> = Vec::new();
for m in monitor_rects() {
let anchor = center(RectPx {
left: m.left,
top: m.top,
right: m.right,
bottom: m.bottom,
});
let (_, dpi) = monitor_at(anchor).expect("画面の情報");
let rect = tip.show("あいうABC", anchor).expect("出す");
pump_for(Duration::from_millis(150));
let (font, made_for) = tip.font.expect("字体");
assert_eq!(made_for, dpi, "その画面の DPI で字体を作った");
let em = em_height(font);
let drawn = text_height(tip.hwnd.expect("窓"), font, "あいうABC");
let window = rect.bottom - rect.top;
println!("[S6] monitor={m:?} dpi={dpi} em={em} 描いた字の高さ={drawn} 窓の高さ={window}");
seen.push((dpi, em, drawn, window));
}
tip.hide();
for a in &seen {
for b in &seen {
if a.0 < b.0 {
let want = f64::from(b.0) / f64::from(a.0);
let em = f64::from(b.1) / f64::from(a.1);
let drawn = f64::from(b.2) / f64::from(a.2);
println!("[S6] DPI の比={want:.3} em の比={em:.3} 描いた字の比={drawn:.3}");
assert!((em / want - 1.0).abs() < 0.05, "em の比が DPI の比に合う");
assert!(
(drawn / want - 1.0).abs() < 0.15,
"描いた字の比が DPI の比に近い"
);
}
}
}
let hwnd = tip.hwnd.expect("窓");
let f96 = tip.ensure_font(hwnd, 96).expect("96");
let (em96, drawn96) = (em_height(f96), text_height(hwnd, f96, "あ"));
let f144 = tip.ensure_font(hwnd, 144).expect("144");
let (em144, drawn144) = (em_height(f144), text_height(hwnd, f144, "あ"));
let ratio = f64::from(em144) / f64::from(em96);
println!(
"[S6] 96dpi: em={em96} 描いた={drawn96} / 144dpi: em={em144} 描いた={drawn144} / em の比={ratio:.3}"
);
assert!((ratio - 1.5).abs() < 0.075, "150% の字は 100% の約 1.5 倍");
}
#[test]
#[ignore = "実機の画面とマウスを使う(--ignored で明示して走らせる)"]
fn s7_stays_in_front_after_another_topmost_window_is_raised() {
per_monitor_aware();
let mut tip = TipWindow::default();
let (work, _) = primary();
let rect = tip.show("最初の試し S7", center(work)).expect("出す");
let hwnd = tip.hwnd.expect("窓");
let other = marker_window(
RectPx {
left: rect.left - 20,
top: rect.top - 20,
right: rect.right + 20,
bottom: rect.bottom + 20,
},
true,
);
unsafe {
SetWindowPos(
other,
Some(HWND_TOPMOST),
0,
0,
0,
0,
SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE,
)
}
.expect("最前面へ");
pump_for(Duration::from_millis(150));
let covered = is_above(other, hwnd);
tip.keep_on_top().expect("当て直す");
pump_for(Duration::from_millis(150));
let in_front = is_above(hwnd, other);
println!("[S7] 当て直しの直後に覆われた={covered} 見回りの当て直しの後に手前={in_front}");
tip.hide();
let _ = unsafe { DestroyWindow(other) };
assert!(in_front, "見回りの当て直しの後はツールチップが手前");
}
#[path = "os_v6_tests.rs"]
mod v6;