use super::geometry::{
PlaceInput, PointPx, RectPx, SizePx, force_break, logical_max_width, max_tip_width,
normalize_newlines, place,
};
use super::turn::FALLBACK_HOVER_TIME;
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tracing::warn;
use windows::Win32::Foundation::{COLORREF, E_FAIL, HWND, LPARAM, POINT, RECT, SIZE, WPARAM};
use windows::Win32::Graphics::Gdi::{
CreateFontIndirectW, DeleteObject, GetDC, GetMonitorInfoW, GetTextExtentExPointW, HFONT,
MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromPoint, ReleaseDC, SelectObject,
};
use windows::Win32::UI::Controls::{
CCM_GETVERSION, ICC_BAR_CLASSES, INITCOMMONCONTROLSEX, InitCommonControlsEx, TOOLTIPS_CLASS,
TTF_ABSOLUTE, TTF_TRACK, TTF_TRANSPARENT, TTM_ADDTOOLW, TTM_ADJUSTRECT, TTM_SETMAXTIPWIDTH,
TTM_TRACKACTIVATE, TTM_TRACKPOSITION, TTM_UPDATETIPTEXTW, TTS_ALWAYSTIP, TTS_NOANIMATE,
TTS_NOFADE, TTS_NOPREFIX, TTTOOLINFOW,
};
use windows::Win32::UI::HiDpi::{
GetDpiForMonitor, GetDpiForWindow, GetSystemMetricsForDpi, MDT_EFFECTIVE_DPI,
SystemParametersInfoForDpi,
};
use windows::Win32::UI::Input::KeyboardAndMouse::{
GetAsyncKeyState, VIRTUAL_KEY, VK_LBUTTON, VK_MBUTTON, VK_RBUTTON, VK_XBUTTON1, VK_XBUTTON2,
};
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DestroyWindow, GetCursorPos, GetWindowRect, HWND_TOPMOST, IsWindowVisible,
LWA_ALPHA, NONCLIENTMETRICSW, SM_CYCURSOR, SPI_GETMOUSEHOVERTIME, SPI_GETNONCLIENTMETRICS,
SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS,
SendMessageW, SetLayeredWindowAttributes, SetWindowPos, SystemParametersInfoW, WINDOW_STYLE,
WM_SETFONT, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST,
WS_EX_TRANSPARENT, WS_POPUP,
};
use windows::core::{PCWSTR, PWSTR};
#[derive(Debug, thiserror::Error)]
pub enum TooltipOsError {
#[error("ツールチップの窓を作れなかった: {0}")]
Create(#[source] windows::core::Error),
#[error("ツールチップを出せなかった({stage})")]
Show { stage: &'static str },
}
pub(crate) struct OsSample {
pub cursor: Option<PointPx>,
pub button_down: bool,
}
pub(crate) fn sample() -> OsSample {
let mut pt = POINT::default();
let cursor = unsafe { GetCursorPos(&mut pt) }
.ok()
.map(|()| PointPx { x: pt.x, y: pt.y });
let buttons: [VIRTUAL_KEY; 5] = [VK_LBUTTON, VK_RBUTTON, VK_MBUTTON, VK_XBUTTON1, VK_XBUTTON2];
let button_down = buttons
.iter()
.any(|vk| unsafe { GetAsyncKeyState(i32::from(vk.0)) } < 0);
OsSample {
cursor,
button_down,
}
}
static HOVER_TIME_WARNED: AtomicBool = AtomicBool::new(false);
pub(crate) fn read_hover_time() -> Duration {
let mut ms: u32 = 0;
let read = unsafe {
SystemParametersInfoW(
SPI_GETMOUSEHOVERTIME,
0,
Some((&raw mut ms).cast()),
SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
)
};
match read {
Ok(()) => Duration::from_millis(u64::from(ms)),
Err(e) => {
if !HOVER_TIME_WARNED.swap(true, Ordering::Relaxed) {
warn!(
error = %e,
fallback_ms = FALLBACK_HOVER_TIME.as_millis() as u64,
"[tooltip_hover_time_unreadable] 待ち時間の設定を読めないので代わりの値で続ける"
);
}
FALLBACK_HOVER_TIME
}
}
}
pub(crate) fn is_visible(hwnd: HWND) -> bool {
unsafe { IsWindowVisible(hwnd) }.as_bool()
}
pub(crate) fn chars_within_utf16(text: &str, units: usize) -> usize {
let mut used = 0;
text.chars()
.take_while(|c| {
used += c.len_utf16();
used <= units
})
.count()
}
const TOOL_ID: usize = 1;
const PARKED: PointPx = PointPx {
x: -32000,
y: -32000,
};
const UNBOUNDED: RectPx = RectPx {
left: i32::MIN / 4,
top: i32::MIN / 4,
right: i32::MAX / 4,
bottom: i32::MAX / 4,
};
#[derive(Default)]
pub(crate) struct TipWindow {
hwnd: Option<HWND>,
font: Option<(HFONT, u32)>,
v6: bool,
}
impl TipWindow {
pub(crate) fn show(&mut self, text: &str, anchor: PointPx) -> Result<RectPx, TooltipOsError> {
let shown = self.try_show(text, anchor);
if shown.is_err() {
self.hide();
}
shown
}
pub(crate) fn hide(&mut self) {
if let Some(hwnd) = self.hwnd {
let mut ti = tool_info(hwnd, PWSTR::null());
unsafe {
SendMessageW(
hwnd,
TTM_TRACKACTIVATE,
Some(WPARAM(0)),
Some(LPARAM((&raw mut ti) as isize)),
);
}
}
}
pub(crate) fn keep_on_top(&self) -> Result<(), TooltipOsError> {
let Some(hwnd) = self.hwnd else {
return Ok(());
};
unsafe {
SetWindowPos(
hwnd,
Some(HWND_TOPMOST),
0,
0,
0,
0,
SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE,
)
}
.map_err(|_| TooltipOsError::Show { stage: "topmost" })
}
fn try_show(&mut self, text: &str, anchor: PointPx) -> Result<RectPx, TooltipOsError> {
let hwnd = self.ensure_window()?;
let (work_area, dpi) = monitor_at(anchor).unwrap_or_else(|| {
warn!(
x = anchor.x,
y = anchor.y,
"[tooltip_monitor_unreadable] 画面の情報を読めないので作業領域に収めずに出す"
);
(UNBOUNDED, 96)
});
if unsafe { GetDpiForWindow(hwnd) } != dpi {
self.hide();
unsafe {
SetWindowPos(
hwnd,
None,
anchor.x,
anchor.y,
0,
0,
SWP_NOACTIVATE | SWP_NOSIZE | SWP_NOZORDER,
)
}
.map_err(|_| TooltipOsError::Show { stage: "dpi" })?;
}
let font = self.ensure_font(hwnd, dpi)?;
let max_width = max_tip_width(dpi, work_area);
let window_dpi = unsafe { GetDpiForWindow(hwnd) };
let os_max_width = if self.v6 {
logical_max_width(max_width, window_dpi)
} else {
max_width
};
unsafe {
SendMessageW(
hwnd,
TTM_SETMAXTIPWIDTH,
Some(WPARAM(0)),
Some(LPARAM(os_max_width as isize)),
);
}
let mut text = normalize_newlines(text);
let mut size = set_text_and_measure(hwnd, &text)?;
if size.width > width_limit(hwnd, max_width, window_dpi) {
text = break_long_lines(hwnd, font, &text, max_width)?;
size = set_text_and_measure(hwnd, &text)?;
}
let cursor_height = unsafe { GetSystemMetricsForDpi(SM_CYCURSOR, dpi) };
let pos = place(&PlaceInput {
anchor,
tip: size,
work_area,
offset_above: scale(20, dpi),
offset_below: cursor_height,
});
track_at(hwnd, pos);
self.keep_on_top()?;
window_rect(hwnd)
}
fn ensure_window(&mut self) -> Result<HWND, TooltipOsError> {
if let Some(hwnd) = self.hwnd {
return Ok(hwnd);
}
let icc = INITCOMMONCONTROLSEX {
dwSize: size_of::<INITCOMMONCONTROLSEX>() as u32,
dwICC: ICC_BAR_CLASSES,
};
let _ = unsafe { InitCommonControlsEx(&icc) };
let hwnd = unsafe {
CreateWindowExW(
WS_EX_TOPMOST
| WS_EX_NOACTIVATE
| WS_EX_TOOLWINDOW
| WS_EX_TRANSPARENT
| WS_EX_LAYERED,
TOOLTIPS_CLASS,
PCWSTR::null(),
WINDOW_STYLE(
WS_POPUP.0 | TTS_NOPREFIX | TTS_ALWAYSTIP | TTS_NOFADE | TTS_NOANIMATE,
),
0,
0,
0,
0,
None,
None,
None,
None,
)
}
.map_err(TooltipOsError::Create)?;
if let Err(e) = unsafe { SetLayeredWindowAttributes(hwnd, COLORREF(0), 255, LWA_ALPHA) } {
let _ = unsafe { DestroyWindow(hwnd) };
return Err(TooltipOsError::Create(e));
}
let mut empty = [0u16];
let mut ti = tool_info(hwnd, PWSTR(empty.as_mut_ptr()));
let added = unsafe {
SendMessageW(
hwnd,
TTM_ADDTOOLW,
Some(WPARAM(0)),
Some(LPARAM((&raw mut ti) as isize)),
)
};
if added.0 == 0 {
let _ = unsafe { DestroyWindow(hwnd) };
return Err(TooltipOsError::Create(windows::core::Error::new(
E_FAIL,
"TTM_ADDTOOLW",
)));
}
let version = unsafe { SendMessageW(hwnd, CCM_GETVERSION, None, None) }.0;
self.v6 = version >= 6;
self.hwnd = Some(hwnd);
Ok(hwnd)
}
fn ensure_font(&mut self, hwnd: HWND, dpi: u32) -> Result<HFONT, TooltipOsError> {
if let Some((font, made_for)) = self.font
&& made_for == dpi
{
return Ok(font);
}
let mut ncm = NONCLIENTMETRICSW {
cbSize: size_of::<NONCLIENTMETRICSW>() as u32,
..Default::default()
};
unsafe {
SystemParametersInfoForDpi(
SPI_GETNONCLIENTMETRICS.0,
ncm.cbSize,
Some((&raw mut ncm).cast()),
0,
dpi,
)
}
.map_err(|_| TooltipOsError::Show { stage: "font" })?;
let font = unsafe { CreateFontIndirectW(&ncm.lfStatusFont) };
if font.is_invalid() {
return Err(TooltipOsError::Show { stage: "font" });
}
unsafe {
SendMessageW(
hwnd,
WM_SETFONT,
Some(WPARAM(font.0 as usize)),
Some(LPARAM(1)),
);
if let Some((old, _)) = self.font.replace((font, dpi)) {
let _ = DeleteObject(old.into());
}
}
Ok(font)
}
}
impl Drop for TipWindow {
fn drop(&mut self) {
unsafe {
if let Some(hwnd) = self.hwnd.take() {
let _ = DestroyWindow(hwnd);
}
if let Some((font, _)) = self.font.take() {
let _ = DeleteObject(font.into());
}
}
}
}
fn tool_info(hwnd: HWND, text: PWSTR) -> TTTOOLINFOW {
TTTOOLINFOW {
cbSize: std::mem::offset_of!(TTTOOLINFOW, lpReserved) as u32,
uFlags: TTF_TRACK | TTF_ABSOLUTE | TTF_TRANSPARENT,
hwnd,
uId: TOOL_ID,
lpszText: text,
..Default::default()
}
}
fn track_at(hwnd: HWND, pos: PointPx) {
let mut ti = tool_info(hwnd, PWSTR::null());
unsafe {
SendMessageW(
hwnd,
TTM_TRACKPOSITION,
Some(WPARAM(0)),
Some(LPARAM(make_lparam(pos.x, pos.y))),
);
SendMessageW(
hwnd,
TTM_TRACKACTIVATE,
Some(WPARAM(1)),
Some(LPARAM((&raw mut ti) as isize)),
);
}
}
fn set_text_and_measure(hwnd: HWND, text: &str) -> Result<SizePx, TooltipOsError> {
let mut wide: Vec<u16> = text.encode_utf16().chain([0]).collect();
let mut ti = tool_info(hwnd, PWSTR(wide.as_mut_ptr()));
unsafe {
SendMessageW(
hwnd,
TTM_UPDATETIPTEXTW,
Some(WPARAM(0)),
Some(LPARAM((&raw mut ti) as isize)),
);
}
if !is_visible(hwnd) {
track_at(hwnd, PARKED);
if !is_visible(hwnd) {
return Err(TooltipOsError::Show { stage: "track" });
}
}
let rc = window_rect(hwnd)?;
let size = SizePx {
width: rc.right - rc.left,
height: rc.bottom - rc.top,
};
if size.width <= 0 || size.height <= 0 {
return Err(TooltipOsError::Show { stage: "size" });
}
Ok(size)
}
fn window_rect(hwnd: HWND) -> Result<RectPx, TooltipOsError> {
let mut rc = RECT::default();
unsafe { GetWindowRect(hwnd, &mut rc) }.map_err(|_| TooltipOsError::Show { stage: "rect" })?;
Ok(RectPx {
left: rc.left,
top: rc.top,
right: rc.right,
bottom: rc.bottom,
})
}
fn width_limit(hwnd: HWND, max_width: i32, window_dpi: u32) -> i32 {
window_width_for(hwnd, max_width) + scale(2, window_dpi)
}
fn window_width_for(hwnd: HWND, text_width: i32) -> i32 {
let mut rc = RECT {
left: 0,
top: 0,
right: text_width,
bottom: 0,
};
let ok = unsafe {
SendMessageW(
hwnd,
TTM_ADJUSTRECT,
Some(WPARAM(1)),
Some(LPARAM((&raw mut rc) as isize)),
)
};
if ok.0 == 0 {
text_width
} else {
rc.right - rc.left
}
}
fn break_long_lines(
hwnd: HWND,
font: HFONT,
text: &str,
max_width: i32,
) -> Result<String, TooltipOsError> {
let hdc = unsafe { GetDC(Some(hwnd)) };
if hdc.is_invalid() {
return Err(TooltipOsError::Show { stage: "measure" });
}
let old = unsafe { SelectObject(hdc, font.into()) };
let failed = Cell::new(false);
let fit = |line: &str| {
let wide: Vec<u16> = line.encode_utf16().collect();
let mut fit_units = 0i32;
let mut extent = SIZE::default();
let ok = unsafe {
GetTextExtentExPointW(
hdc,
PCWSTR(wide.as_ptr()),
wide.len() as i32,
max_width,
Some(&mut fit_units),
None,
&mut extent,
)
};
if ok.as_bool() {
chars_within_utf16(line, fit_units.max(0) as usize)
} else {
failed.set(true);
usize::MAX
}
};
let broken = force_break(text, &fit);
unsafe {
SelectObject(hdc, old);
ReleaseDC(Some(hwnd), hdc);
}
if failed.get() {
return Err(TooltipOsError::Show { stage: "measure" });
}
Ok(broken)
}
fn monitor_at(p: PointPx) -> Option<(RectPx, u32)> {
unsafe {
let monitor = MonitorFromPoint(POINT { x: p.x, y: p.y }, MONITOR_DEFAULTTONEAREST);
let mut mi = MONITORINFO {
cbSize: size_of::<MONITORINFO>() as u32,
..Default::default()
};
if !GetMonitorInfoW(monitor, &mut mi).as_bool() {
return None;
}
let (mut dpi_x, mut dpi_y) = (0u32, 0u32);
GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &mut dpi_x, &mut dpi_y).ok()?;
let wa = mi.rcWork;
Some((
RectPx {
left: wa.left,
top: wa.top,
right: wa.right,
bottom: wa.bottom,
},
dpi_x,
))
}
}
fn scale(logical: i32, dpi: u32) -> i32 {
((i64::from(logical) * i64::from(dpi) + 48) / 96) as i32
}
fn make_lparam(x: i32, y: i32) -> isize {
((u32::from(y as u16) << 16) | u32::from(x as u16)) as isize
}
#[cfg(test)]
#[path = "os_tests.rs"]
mod os_tests;