Windows: a pen moves and resizes the window through the custom title bar (#7967) (#12992)

* Windows: a pen or a finger moves and resizes the window through the custom title bar (#7967)

winit takes pen and touch input as WM_POINTER messages and Windows makes no mouse
input from them, so the OS move/size loop that StartDrag/BeginResize start saw no
button down and never ran. winit only clears its dragging flag when that loop ends,
so after one pen try the mouse could no longer move the window either. A pen or
touch press on the title bar's gap or an edge now moves or resizes the window
itself (OuterPosition/InnerSize), restores a maximized window under the pen, and
stops at the minimum size; the mouse still hands the move to the OS.

* Pen title-bar move and resize follow the pen's own screen position (#7967)

On Windows winit moves the window asynchronously (SWP_ASYNCWINDOWPOS), so the
window position egui reads can be ahead of the window-relative pen position and
the drag counted the move twice (an injected pen ran +687/+909 for +200/+120).
The tablet hook now keeps the pen's screen pixels from GetPointerPenInfo
(ptPixelLocation) while the pen is down, and a PenDrag follows that.

A drag that follows the OS position no longer needs egui's pointer position: the
parked mouse cursor can leave the window as it moves away, and egui-winit's
PointerGone ended the drag after a few steps. It ends on release or when no
touch is down.
This commit is contained in:
FredApps authored and GitHub committed 2026-11-11 01:08:12 +01:00
1 parent c908c79235
commit 847d87bb9b
9 files changed
+480 -14

No files matched your search

+2 -1
View File
@@ -153,7 +153,7 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
.with_app_id(APP_ID)
.with_title("PhotoCraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([760.0, 480.0])
.with_min_inner_size(photocraft_ui_egui::MIN_WINDOW_SIZE)
.with_drag_and_drop(true)
.with_decorations(!custom_titlebar)
.with_fullsize_content_view(true)
@@ -682,6 +682,7 @@ fn main() -> eframe::Result {
tablet::spawn_x11(&app.stylus.feed, display);
app.services.right_button_down = tablet::right_button_service();
app.services.pen_end = tablet::pen_end_service();
app.services.pen_screen_px = tablet::pen_screen_service();
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
app.open_paths(&files);
if !unreadable_paths.is_empty() {
+11
View File
@@ -92,6 +92,17 @@ pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
None
}
/// Where the pen is on the screen (Windows), for the title bar's pen moves (#7967).
#[cfg(target_os = "windows")]
pub fn pen_screen_service() -> Option<photocraft_ui_egui::PenScreenFn> {
Some(Box::new(|| photocraft_tablet::windows::pen_screen_px().map(|(x, y)| [x, y])))
}
#[cfg(not(target_os = "windows"))]
pub fn pen_screen_service() -> Option<photocraft_ui_egui::PenScreenFn> {
None
}
/// Which end of the pen is in use (Windows): flipping the pen to its eraser end selects the
/// Eraser (#2285). Elsewhere the end arrives with the samples in the stylus feed.
#[cfg(target_os = "windows")]
+41 -4
View File
@@ -11,7 +11,7 @@
#![allow(unsafe_code, clippy::upper_case_acronyms)]
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
static BARREL_DOWN: AtomicBool = AtomicBool::new(false);
@@ -25,6 +25,10 @@ static PEN_END: AtomicU8 = AtomicU8::new(0);
/// The pen end of the last contact [`take_pen_end`] has not read yet, as [`encode_end`] stores it.
static CONTACT_END: AtomicU8 = AtomicU8::new(0);
/// Where the pen was on the screen at its last pointer message (`ptPixelLocation`, physical
/// pixels), [`pack_px`]ed, or [`NO_PX`]. Read by [`pen_screen_px`].
static PEN_PX: AtomicU64 = AtomicU64::new(NO_PX);
const NO_PX: u64 = u64::MAX;
fn encode_end(end: Option<bool>) -> u8 {
match end {
@@ -213,15 +217,39 @@ pub fn handle_pointer_message(message: u32, w_param: usize, pen_flags: impl FnOn
PEN_END.store(encode_end(next.end), Ordering::Relaxed);
PEN_ID.store(next.pen_id.unwrap_or(NO_PEN), Ordering::Relaxed);
CONTACT_END.store(encode_end(next.contact), Ordering::Relaxed);
if next.pen_id.is_none() {
PEN_PX.store(NO_PX, Ordering::Relaxed);
}
}
/// `GetPointerPenInfo`'s pen flags for a pointer (`None`: not a pen). It also keeps where the pen
/// is on the screen ([`pen_screen_px`]).
fn read_pen_flags(pointer_id: u32) -> Option<u32> {
// SAFETY: `info` is a stack-allocated buffer initialized to zero that outlives the call, and
// `pointer_id` comes from a WM_POINTER message; an unknown id makes the call fail, not crash.
unsafe {
let info = unsafe {
let mut info: POINTER_PEN_INFO = std::mem::zeroed();
(GetPointerPenInfo(pointer_id, &mut info) != 0).then_some(info.pen_flags)
}
(GetPointerPenInfo(pointer_id, &mut info) != 0).then_some(info)
}?;
let at = &info.pointer_info.pt_pixel_location;
PEN_PX.store(pack_px(at.x, at.y), Ordering::Relaxed);
Some(info.pen_flags)
}
fn pack_px(x: i32, y: i32) -> u64 {
(u64::from(x as u32) << 32) | u64::from(y as u32)
}
fn unpack_px(v: u64) -> Option<(i32, i32)> {
(v != NO_PX).then_some(((v >> 32) as u32 as i32, v as u32 as i32))
}
/// Where the pen in range is on the screen, in physical pixels, as of its last pointer message;
/// `None` with no pen in range. Unlike a window-relative position it doesn't depend on where the
/// window is, so the title bar can move the window after the pen (#7967) while Windows is still
/// moving it (winit moves windows asynchronously). The system cursor doesn't follow every pen.
pub fn pen_screen_px() -> Option<(i32, i32)> {
PEN_ID.load(Ordering::Relaxed).ne(&NO_PEN).then(|| unpack_px(PEN_PX.load(Ordering::Relaxed))).flatten()
}
/// `WH_GETMESSAGE`: the posted pointer messages, as the UI thread takes them from its queue.
@@ -311,6 +339,7 @@ impl Drop for Monitor {
PEN_END.store(encode_end(None), Ordering::Relaxed);
PEN_ID.store(NO_PEN, Ordering::Relaxed);
CONTACT_END.store(encode_end(None), Ordering::Relaxed);
PEN_PX.store(NO_PX, Ordering::Relaxed);
}
}
@@ -339,6 +368,14 @@ mod tests {
const POINTER_FLAG_INCONTACT: u32 = 0x0004;
const POINTER_FLAG_FIRSTBUTTON: u32 = 0x0010;
#[test]
fn pen_screen_positions_round_trip_left_of_and_above_the_primary_monitor() {
for (x, y) in [(0, 0), (1140, 214), (-2400, -300), (i32::MAX, i32::MIN)] {
assert_eq!(unpack_px(pack_px(x, y)), Some((x, y)));
}
assert_eq!(unpack_px(NO_PX), None);
}
#[test]
fn plain_pen_contact_is_not_treated_as_barrel() {
// Plain tip touch on digitizer: INCONTACT is set, first button may be set, no barrel flag.
+2
View File
@@ -246,6 +246,8 @@ impl PhotocraftApp {
/// wait modal took the frame (its input, IME and window check already handled).
fn ui_body(&mut self, ui: &mut egui::Ui) -> bool {
let ctx = ui.ctx().clone();
// Where a pen dragging the window is on the screen, before the title bar reads it.
titlebar::note_os_pointer(self, &ctx);
// A text field's right-click menu, before any field is drawn (text_field_menu.rs).
text_field_menu::show(&ctx);
// The wheel goes to a focused value field before the canvas or a panel sees it (#5473).
+3 -3
View File
@@ -57,7 +57,7 @@ pub mod screenshot;
pub mod state;
pub mod texel_guard;
pub mod theme;
pub use titlebar::{Caption, CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle, RenderIconFn};
pub use titlebar::{Caption, CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle, MIN_WINDOW_SIZE, RenderIconFn};
pub(crate) mod tool_memory;
pub mod updates_ui;
pub mod widgets;
@@ -264,8 +264,8 @@ pub use services::{
AdoptAutosaveFn, AppendTextFn, AutomationCommandFn, AutomationOpenPcraftFn, AutomationReadFn, AutomationWriteFn, AutosaveFn, AutosaveResultsFn, CapsLockFn,
CaptionStyleFn, ClipboardGetFn, ClipboardGetSvgFn, ClipboardSetFn, ClipboardUnreadableFn, CursorPosFn, DiscardAutosaveFn, EncodePngFn, ExportFn,
ExportSettings, ExternalSvgEditor, ExternalSvgSession, ExternalVectorSave, ExternalVectorSource, FileStamp, FileStampFn, FormatChoices, ImportFn, Inbox,
LastUpdateFn, LoadTextFn, OpenExternalSvgFn, OpenUrlFn, OsEventsFn, PenEndFn, PhotoshopShortcutsFn, PlaceInbox, QuitFn, RecoverFn, Recoverable,
RightButtonDownFn, SameFileFn, SaveFileFn, SaveTextFn, Services, SystemThemeFn, TabletPollFn, WindowFrameFn, WriteFn,
LastUpdateFn, LoadTextFn, OpenExternalSvgFn, OpenUrlFn, OsEventsFn, PenEndFn, PenScreenFn, PhotoshopShortcutsFn, PlaceInbox, QuitFn, RecoverFn,
Recoverable, RightButtonDownFn, SameFileFn, SaveFileFn, SaveTextFn, Services, SystemThemeFn, TabletPollFn, WindowFrameFn, WriteFn,
};
pub(crate) use services::{opaque_layer, saves_flat_in_place};
pub use text::bidi_text;
+1
View File
@@ -34,6 +34,7 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let gap = egui::Rect::from_x_y_ranges(gap_l.max(full.left())..=gap_r.min(full.right()).max(gap_l), full.y_range());
ui.ctx().data_mut(|d| d.insert_temp(crate::titlebar::modal_drag_area_id(), gap));
let drag = ui.interact(gap, ui.scope_id().with("titledrag"), Sense::click_and_drag());
crate::titlebar::follow_pen_drag(ui.ctx());
if drag.drag_started_by(egui::PointerButton::Primary) {
crate::titlebar::start_os_move(ui.ctx());
}
+195 -3
View File
@@ -10,7 +10,7 @@
//! Breeze's (KDE) or a plain round set elsewhere, with the set of buttons and their side taken
//! from the desktop's button layout. The shapes are drawn here, not read from the icon theme.
use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Pos2, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Pos2, Rect, ResizeDirection, Sense, Stroke, Ui, Vec2, ViewportCommand, pos2, vec2};
use serde_json::json;
use crate::PhotocraftApp;
@@ -22,6 +22,9 @@ pub const BUTTON_WIDTH: f32 = 46.0;
/// (#1581); 8 pt is about a native resize border, and only trims the toolbar buttons' outer 3 pt.
const EDGE: f32 = 8.0;
const CORNER: f32 = 12.0;
/// The main window's minimum inner size (`apps/photocraft`'s viewport). A pen resize
/// ([`PenDrag`]) stops there, as the OS's own resize does.
pub const MIN_WINDOW_SIZE: Vec2 = vec2(760.0, 480.0);
const MODAL_DRAG_AREA: &str = "titlebar-modal-drag-area";
/// Breeze's Close button on hover and press (KDE's Breeze decoration colours).
const BREEZE_CLOSE_HOVER: Color32 = Color32::from_rgb(0xff, 0x98, 0xa2);
@@ -485,11 +488,198 @@ fn hand_off(ctx: &egui::Context) {
/// Move the window from this press. The window manager takes the press of an OS move as it does
/// that of a resize and keeps its release, so egui kept the drag: every other move failed, and a
/// resize right after one (#5725). The same hand-off ends it.
///
/// A pen or touch press moves the window itself instead ([`PenDrag`], #7967).
pub(crate) fn start_os_move(ctx: &egui::Context) {
if start_pen_drag(ctx, PenDragKind::Move) {
return;
}
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
hand_off(ctx);
}
/// What a pen or touch press on the title bar or an edge does.
#[derive(Clone, Copy, Debug, PartialEq)]
enum PenDragKind {
Move,
Resize(ResizeDirection),
}
/// A window move or resize by a pen or a finger, done here rather than by the OS (#7967, #11840).
/// winit's `drag_window` / `drag_resize_window` post `WM_NCLBUTTONDOWN`, and Windows' move and
/// size loop follows the mouse button. winit takes pen and touch input as `WM_POINTER` messages
/// (`WindowEvent::Touch`) and Windows makes no mouse input from them, so the loop saw no button
/// down and never ran: the window stayed put. winit only clears its "dragging" flag when that loop
/// ends (`WM_EXITSIZEMOVE`), so after the first pen try it ignored every later `drag_window`,
/// the mouse's too, until restart. A pen press therefore never asks the OS: the window follows
/// the pen through `OuterPosition` / `InnerSize`, from where the pen is on the screen (the
/// window's inner rect plus the pointer), so moving the window under the pen changes nothing.
#[derive(Clone, Copy, Debug)]
struct PenDrag {
kind: PenDragKind,
/// The press in screen points, and the window's outer and inner rects then. `None` while a
/// maximized window restores before it moves.
start: Option<(Pos2, Rect, Rect)>,
/// Where the press was in the maximized window, as a fraction of its width and points from
/// its top: the restored window is placed so the pen holds it at the same spot.
grab: (f32, f32),
/// The last pass this was applied in, so the title bar and the edge zones can both call
/// [`follow_pen_drag`].
pass: u64,
/// The OS reports where the pen is on the screen ([`note_os_pointer`]): the drag follows that.
/// Otherwise it follows the window's inner rect plus the pointer, which can run ahead.
os: bool,
}
fn os_pointer_id() -> Id {
Id::unique("titlebar-os-pointer")
}
/// Read the pen's screen position from the OS for this pass while a pen or a finger is down. A
/// [`PenDrag`] follows that: winit moves the window asynchronously (`SWP_ASYNCWINDOWPOS`), so a
/// pointer position egui got before a move landed, added to the window position read after it,
/// counted the move twice, and the window ran away from the pen (an injected pen on Windows 11:
/// +687, +909 for a +200, +120 stroke). The pen's own pointer messages
/// (`Services::pen_screen_px`) say where it is; failing that the OS cursor
/// (`Services::cursor_pos`, relative to the window's inner rect, so the sum is the screen
/// position), which a pen doesn't always move. Call before the title bar and the edge zones.
pub(crate) fn note_os_pointer(app: &mut PhotocraftApp, ctx: &egui::Context) {
if !PEN_DRAG || !ctx.input(|i| i.any_touches()) {
return;
}
let pen = app.services.pen_screen_px.as_ref().and_then(|f| f()).map(|[x, y]| pos2(x as f32, y as f32) / ctx.pixels_per_point());
let cursor = || {
let inner = ctx.input(|i| i.viewport().inner_rect)?;
app.services.cursor_pos.as_mut().and_then(|f| f(ctx)).map(|at| inner.min + at.to_vec2())
};
let Some(at) = pen.or_else(cursor) else { return };
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| d.insert_temp(os_pointer_id(), (pass, at)));
}
/// The screen position [`note_os_pointer`] read this pass.
fn os_pointer(ctx: &egui::Context) -> Option<Pos2> {
let (pass, at) = ctx.data(|d| d.get_temp::<(u64, Pos2)>(os_pointer_id()))?;
(pass == ctx.cumulative_pass_nr()).then_some(at)
}
fn pen_drag_id() -> Id {
Id::unique("titlebar-pen-drag")
}
/// Only Windows needs it, and only Windows can place a window from the app everywhere (Wayland
/// lets no client place its window). Tests run it on every platform.
const PEN_DRAG: bool = cfg!(any(target_os = "windows", test));
/// Start a [`PenDrag`] if the press is a pen's or a finger's: true if it took the press.
fn start_pen_drag(ctx: &egui::Context, kind: PenDragKind) -> bool {
if !PEN_DRAG || !ctx.input(|i| i.any_touches()) {
return false;
}
let (inner, outer, maximized) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().outer_rect, i.viewport().maximized.unwrap_or(false)));
let (Some(inner), Some(outer), Some(press)) = (inner, outer, ctx.input(|i| i.pointer.press_origin().or(i.pointer.latest_pos()))) else {
return false;
};
let pass = ctx.cumulative_pass_nr();
// Trust the OS's position only if it is where the pointer is: a finger leaves the cursor
// behind.
let latest = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(press);
let os_now = os_pointer(ctx).filter(|s| (*s - (inner.min + latest.to_vec2())).length() <= 8.0);
let os = os_now.is_some();
let drag = if maximized {
if kind != PenDragKind::Move {
return false;
}
ctx.send_viewport_cmd(ViewportCommand::Maximized(false));
PenDrag { kind, start: None, grab: (press.x / inner.width().max(1.0), press.y), pass, os }
} else {
let at = os_now.map_or(inner.min + press.to_vec2(), |s| s - (latest - press));
PenDrag { kind, start: Some((at, outer, inner)), grab: (0.0, 0.0), pass, os }
};
ctx.data_mut(|d| d.insert_temp(pen_drag_id(), drag));
true
}
/// Move or resize the window after the pen ([`PenDrag`]); ends with the press. Every pass, from
/// the title bar and the edge zones.
pub(crate) fn follow_pen_drag(ctx: &egui::Context) {
let Some(mut drag) = ctx.data(|d| d.get_temp::<PenDrag>(pen_drag_id())) else { return };
let pass = ctx.cumulative_pass_nr();
if drag.pass == pass {
return;
}
drag.pass = pass;
// The pen is down while egui has the press and a touch. egui's pointer can be gone meanwhile:
// the mouse cursor, parked where the window was, leaves the window as it moves away (winit's
// `CursorLeft`), which only matters when the drag goes by that pointer.
let (pos, down) = ctx.input(|i| (i.pointer.latest_pos(), i.pointer.primary_down() && i.any_touches()));
let (inner, outer, maximized) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().outer_rect, i.viewport().maximized.unwrap_or(false)));
let end = |ctx: &egui::Context| ctx.data_mut(|d| d.remove::<PenDrag>(pen_drag_id()));
let (Some(inner), Some(outer), true) = (inner, outer, down) else { return end(ctx) };
let pen = if drag.os {
// No OS reading this pass (the service failed): wait for the next.
let Some(at) = os_pointer(ctx) else {
ctx.data_mut(|d| d.insert_temp(pen_drag_id(), drag));
return;
};
at
} else {
let Some(pos) = pos else { return end(ctx) };
inner.min + pos.to_vec2()
};
let Some((press, outer0, inner0)) = drag.start else {
// Restoring a maximized window: wait for its restored size, then put it under the pen.
if !maximized {
let at = pen - vec2(drag.grab.0 * inner.width(), drag.grab.1) - (inner.min - outer.min);
ctx.send_viewport_cmd(ViewportCommand::OuterPosition(at));
let shift = at - outer.min;
drag.start = Some((pen, outer.translate(shift), inner.translate(shift)));
}
ctx.data_mut(|d| d.insert_temp(pen_drag_id(), drag));
return;
};
let d = pen - press;
match drag.kind {
PenDragKind::Move => {
let at = outer0.min + d;
if at != outer.min {
ctx.send_viewport_cmd(ViewportCommand::OuterPosition(at));
}
}
PenDragKind::Resize(dir) => {
let r = resized(inner0, dir, d, MIN_WINDOW_SIZE);
if r.size() != inner.size() {
ctx.send_viewport_cmd(ViewportCommand::InnerSize(r.size()));
}
let at = outer0.min + (r.min - inner0.min);
if at != outer.min {
ctx.send_viewport_cmd(ViewportCommand::OuterPosition(at));
}
}
}
ctx.data_mut(|d| d.insert_temp(pen_drag_id(), drag));
}
/// `r` with the edges `dir` names moved by `d`, no smaller than `min`: a left or top edge stops
/// where the window would be too small rather than pushing it along.
fn resized(r: Rect, dir: ResizeDirection, d: Vec2, min: Vec2) -> Rect {
use ResizeDirection::*;
let mut r = r;
if matches!(dir, West | NorthWest | SouthWest) {
r.min.x = (r.min.x + d.x).min(r.max.x - min.x);
}
if matches!(dir, East | NorthEast | SouthEast) {
r.max.x = (r.max.x + d.x).max(r.min.x + min.x);
}
if matches!(dir, North | NorthWest | NorthEast) {
r.min.y = (r.min.y + d.y).min(r.max.y - min.y);
}
if matches!(dir, South | SouthWest | SouthEast) {
r.max.y = (r.max.y + d.y).max(r.min.y + min.y);
}
r
}
/// The compositor takes the button release of an OS resize or move (KDE Wayland, KWin on X11), so
/// egui kept the edge pressed: the zones stopped hovering until the next click, every other resize
/// (#1581, and #5725 for moves). End the press ourselves on the input after the hand-off, but only
@@ -525,9 +715,11 @@ fn reshow_cursor_after_os_resize(ctx: &egui::Context) {
}
/// Invisible resize zones along the window's edges, above everything else; a press in one starts
/// an OS resize in that direction. None while the window is maximized or full screen.
/// an OS resize in that direction (a pen's or a finger's: a [`PenDrag`]). None while the window
/// is maximized or full screen.
pub fn resize_zones(ui: &mut Ui) {
let ctx = ui.ctx().clone();
follow_pen_drag(&ctx);
if maximized(&ctx) || ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) {
reshow_cursor_after_os_resize(&ctx);
return;
@@ -537,7 +729,7 @@ pub fn resize_zones(ui: &mut Ui) {
let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w));
for (i, (r, dir, cursor)) in zones(w).into_iter().enumerate() {
let resp = zui.interact(r, Id::unique(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor);
if resp.drag_started_by(PointerButton::Primary) {
if resp.drag_started_by(PointerButton::Primary) && !start_pen_drag(&ctx, PenDragKind::Resize(dir)) {
ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir));
hand_off(&ctx);
}
+219 -3
View File
@@ -1,5 +1,5 @@
use super::*;
use egui::{Event, Pos2, RawInput, ViewportId};
use egui::{Event, Pos2, RawInput, Vec2, ViewportId};
use photocraft_engine::Session;
/// #5826: the drag overlay under a dialog doesn't depend on the custom title bar. On macOS
@@ -24,8 +24,22 @@ struct Win {
ctx: egui::Context,
app: PhotocraftApp,
width: f32,
height: f32,
/// Where the window's content is on the screen, and its outer rect's offset from that.
at: Pos2,
frame_offset: Vec2,
time: f64,
maximized: bool,
/// Windows moves the window asynchronously: with `lag`, the pointer positions of a frame are
/// relative to where the window was before the previous frame's commands, while the viewport
/// already reports where it is now.
lag: bool,
before_move: Pos2,
/// The OS cursor on the screen, when it follows the pen (`Services::cursor_pos`).
cursor: std::rc::Rc<std::cell::Cell<Option<Pos2>>>,
cursor_follows: bool,
/// A pen frame, by its index in the path, where egui's pointer also leaves the window.
pointer_gone_at: Option<usize>,
}
impl Win {
@@ -34,7 +48,30 @@ impl Win {
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
let mut app = PhotocraftApp::new(Session::new(), crate::Services::default());
app.custom_titlebar = true;
Self { ctx, app, width, time: 0.0, maximized: false }
let at = pos2(200.0, 100.0);
Self {
ctx,
app,
width,
height: 600.0,
at,
frame_offset: vec2(-7.0, 0.0),
time: 0.0,
maximized: false,
lag: false,
before_move: at,
cursor: Default::default(),
cursor_follows: true,
pointer_gone_at: None,
}
}
/// The OS cursor follows the pen, as Windows Ink moves it: `Services::cursor_pos` reports it
/// relative to the window's client area.
fn with_os_cursor(mut self) -> Self {
let cursor = self.cursor.clone();
self.app.services.cursor_pos = Some(Box::new(move |ctx: &egui::Context| Some(cursor.get()? - ctx.input(|i| i.viewport().inner_rect)?.min.to_vec2())));
self
}
fn frame(&mut self, events: Vec<Event>) -> egui::FullOutput {
@@ -44,14 +81,19 @@ impl Win {
fn frame_with_modal(&mut self, events: Vec<Event>, modal: bool) -> egui::FullOutput {
self.time += 0.05;
let mut input =
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 600.0))), time: Some(self.time), events, ..Default::default() };
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, self.height))), time: Some(self.time), events, ..Default::default() };
if let Some(v) = input.viewports.get_mut(&ViewportId::ROOT) {
v.maximized = Some(self.maximized);
let inner = Rect::from_min_size(self.at, vec2(self.width, self.height));
v.inner_rect = Some(inner);
v.outer_rect = Some(Rect::from_min_max(inner.min + self.frame_offset, inner.max - self.frame_offset));
}
// What `PhotocraftApp::raw_input_hook` does.
release_after_os_resize(&self.ctx, &mut input);
let app = &mut self.app;
let mut out = self.ctx.run_ui(input, |ui| {
// What `ui_body` does first.
note_os_pointer(app, ui.ctx());
crate::panels::title_bar(app, ui);
if modal {
egui::Modal::new(Id::unique("titlebar-drag-test-modal")).backdrop_color(Color32::TRANSPARENT).show(ui.ctx(), |ui| {
@@ -85,6 +127,57 @@ impl Win {
.collect()
}
/// A pen stroke along `path`, in screen points, as winit and egui-winit deliver it on Windows:
/// `Touch` events with an emulated pointer at the pen's spot in the window. The window obeys
/// the move, size and restore commands the frames send, as the OS would, so a window that
/// moves under the pen moves the pen's spot in it. The commands the frames sent.
fn pen(&mut self, path: &[Pos2]) -> Vec<ViewportCommand> {
let touch = |phase, pos| Event::Touch { device_id: egui::TouchDeviceId(7), id: egui::TouchId(1), phase, pos, force: Some(0.5) };
let mut all = vec![];
let last = path.len() - 1;
// The pen hovers first: winit reports that as touch moves, egui-winit as pointer moves.
let hover = path[0] - self.at.to_vec2();
all.extend(self.obey(vec![Event::PointerMoved(hover)]));
for (i, p) in path.iter().enumerate() {
if self.cursor_follows {
self.cursor.set(Some(*p));
}
let local = *p - if self.lag { self.before_move } else { self.at }.to_vec2();
let events = match i {
0 => vec![touch(egui::TouchPhase::Start, local), Event::PointerMoved(local), button(local, true, egui::PointerButton::Primary)],
_ => vec![touch(egui::TouchPhase::Move, local), Event::PointerMoved(local)],
};
let mut events = events;
if self.pointer_gone_at == Some(i) {
events.push(Event::PointerGone);
}
all.extend(self.obey(events));
if i == last {
let events = vec![touch(egui::TouchPhase::End, local), button(local, false, egui::PointerButton::Primary), Event::PointerGone];
all.extend(self.obey(events));
}
}
all
}
/// One frame; the window then moves, resizes and restores as its commands say.
fn obey(&mut self, events: Vec<Event>) -> Vec<ViewportCommand> {
let cmds = self.frame(events).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default();
self.before_move = self.at;
for c in &cmds {
match c {
ViewportCommand::OuterPosition(p) => self.at = *p - self.frame_offset,
ViewportCommand::InnerSize(s) => (self.width, self.height) = (s.x, s.y),
ViewportCommand::Maximized(false) => {
self.maximized = false;
(self.at, self.width, self.height) = (pos2(300.0, 200.0), 1000.0, 700.0);
}
_ => {}
}
}
cmds
}
fn click(&mut self, p: Pos2) -> Vec<ViewportCommand> {
self.gesture(&[p])
}
@@ -489,3 +582,126 @@ fn theme_icons_are_cached_and_fall_back() {
out.textures_delta.clear();
assert_eq!(calls.load(Ordering::Relaxed), 3, "each icon and size rendered once; absurd sizes never");
}
/// #7967, #11840: a pen on the title bar's gap moves the window itself. Windows' move loop follows
/// the mouse button, which a pen never presses (winit reads it as `WM_POINTER`), and winit's
/// unfinished `drag_window` then stopped the mouse moving the window too.
#[test]
fn a_pen_moves_the_window_and_the_mouse_still_can_after() {
let mut w = Win::new(1440.0);
w.frame(vec![]);
let empty = pos2(980.0, 16.0);
assert!(w.bar_widgets().iter().all(|r| !r.contains(empty)), "the probe point must be empty bar space");
let start = w.at;
let on_screen = start + empty.to_vec2();
let path: Vec<Pos2> = (0..=10).map(|i| on_screen + vec2(-30.0, 12.0) * i as f32).collect();
let cmds = w.pen(&path);
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag | ViewportCommand::BeginResize(_))), "no OS move for a pen: {cmds:?}");
assert_eq!(w.at, start + vec2(-300.0, 120.0), "the window followed the pen: {cmds:?}");
// Lifting the pen ends it: a move of the pointer moves nothing.
let cmds = w.obey(vec![Event::PointerMoved(pos2(500.0, 300.0))]);
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::OuterPosition(_))), "{cmds:?}");
// The mouse still hands the move to the OS.
let cmds = w.gesture(&[empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]);
assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::OuterPosition(_))), "{cmds:?}");
}
/// A pen on an edge or a corner resizes the window itself, down to the minimum size, the left
/// and top edges without pushing the window along once it is that small.
#[test]
fn a_pen_resizes_the_window_from_its_edges() {
let mut w = Win::new(1000.0);
w.frame(vec![]);
let screen = |w: &Win, p: Pos2| w.at + p.to_vec2();
// South-east corner: 60 wider, 40 taller.
let p = screen(&w, pos2(998.0, 598.0));
let cmds = w.pen(&[p, p + vec2(30.0, 20.0), p + vec2(60.0, 40.0)]);
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}");
assert_eq!((w.width, w.height), (1060.0, 640.0));
let at = w.at;
// West edge, 100 in: the window narrows and its left edge follows the pen.
let p = screen(&w, pos2(2.0, 300.0));
w.pen(&[p, p + vec2(50.0, 0.0), p + vec2(100.0, 0.0)]);
assert_eq!((w.width, w.at), (960.0, at + vec2(100.0, 0.0)));
// North edge, far down: stops at the minimum height with the bottom edge kept.
let bottom = w.at.y + w.height;
let p = screen(&w, pos2(480.0, 1.0));
w.pen(&[p, p + vec2(0.0, 200.0), p + vec2(0.0, 900.0)]);
assert_eq!((w.height, w.at.y + w.height), (MIN_WINDOW_SIZE.y, bottom));
// The mouse still resizes through the OS.
let cmds = w.gesture(&[pos2(1.0, 300.0), pos2(8.0, 300.0)]);
assert_eq!(cmds, vec![ViewportCommand::BeginResize(ResizeDirection::West)]);
}
/// A pen dragging a maximized window's title bar restores it and carries it, held where the pen
/// took it, as Windows does for the mouse.
#[test]
fn a_pen_drags_a_maximized_window_out_restored() {
let mut w = Win::new(1920.0);
w.maximized = true;
w.at = Pos2::ZERO;
w.frame(vec![]);
let grab = pos2(1200.0, 16.0);
assert!(w.bar_widgets().iter().all(|r| !r.contains(grab)), "the probe point must be empty bar space");
let path: Vec<Pos2> = (0..=6).map(|i| grab + vec2(10.0, 20.0) * i as f32).collect();
let cmds = w.pen(&path);
assert!(cmds.contains(&ViewportCommand::Maximized(false)), "{cmds:?}");
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{cmds:?}");
let pen = *path.last().unwrap();
let held = pen - w.at;
assert!((held.x - 1000.0 * 1200.0 / 1920.0).abs() < 0.01 && (held.y - 16.0).abs() < 0.01, "held at {held:?}, window at {:?}", w.at);
}
/// Windows moves the window asynchronously, so a frame's pointer positions can be relative to
/// where the window was before the last move: added to where it is now they counted that move
/// twice, and the window ran away from the pen (seen with an injected pen on Windows 11: +477,
/// +783 for a +200, +120 stroke). With the OS cursor following the pen, the window follows the
/// cursor's screen position instead and lands exactly under the pen.
#[test]
fn a_pen_move_follows_the_os_cursor_when_the_window_moves_late() {
let stroke = |w: &mut Win| {
w.lag = true;
w.frame(vec![]);
let start = w.at;
let on_screen = start + pos2(980.0, 16.0).to_vec2();
let path: Vec<Pos2> = (0..=10).map(|i| on_screen + vec2(20.0, 12.0) * i as f32).collect();
w.pen(&path);
w.at - start
};
assert_eq!(stroke(&mut Win::new(1440.0).with_os_cursor()), vec2(200.0, 120.0));
// Without the cursor the window can only go by egui's positions: it overshoots.
let moved = stroke(&mut Win::new(1440.0));
assert!(moved.x > 200.0 && moved.y > 120.0, "{moved:?}");
}
/// A finger leaves the OS cursor behind: a cursor that isn't where the pointer is isn't followed.
#[test]
fn a_cursor_away_from_the_pen_is_not_followed() {
let mut w = Win::new(1440.0).with_os_cursor();
w.cursor_follows = false;
w.cursor.set(Some(pos2(5.0, 5.0)));
w.frame(vec![]);
let start = w.at;
let on_screen = start + pos2(980.0, 16.0).to_vec2();
let path: Vec<Pos2> = (0..=10).map(|i| on_screen + vec2(-30.0, 12.0) * i as f32).collect();
w.pen(&path);
assert_eq!(w.at, start + vec2(-300.0, 120.0));
}
/// The mouse cursor, parked where the pen took the window, can end up outside the window as it
/// moves away: winit reports the mouse leaving, and egui's pointer is gone for the rest of the
/// stroke. Seen on Windows 11 after a mouse move: the pen move stopped after 2 of 20 steps. The
/// drag follows the pen's own position, so it carries on.
#[test]
fn a_pen_move_survives_the_mouse_pointer_leaving_the_window() {
let mut w = Win::new(1440.0).with_os_cursor();
w.lag = true;
w.pointer_gone_at = Some(3);
w.frame(vec![]);
let start = w.at;
let on_screen = start + pos2(980.0, 16.0).to_vec2();
let path: Vec<Pos2> = (0..=10).map(|i| on_screen + vec2(20.0, 12.0) * i as f32).collect();
w.pen(&path);
assert_eq!(w.at - start, vec2(200.0, 120.0));
}
+6
View File
@@ -477,6 +477,9 @@ pub type CaptionStyleFn = Box<dyn Fn(&egui::Context) -> CaptionStyle>;
/// Physical right mouse button or stylus barrel button down (Windows; read for brush resize).
pub type RightButtonDownFn = Box<dyn Fn() -> bool + Send + Sync>;
/// Where the pen in range is on the screen, in physical pixels ([`Services::pen_screen_px`]).
pub type PenScreenFn = Box<dyn Fn() -> Option<[i32; 2]> + Send + Sync>;
/// Which end of the pen is in use, `Some(true)` = eraser, `None` = no pen ([`Services::pen_end`]).
pub type PenEndFn = Box<dyn Fn() -> Option<bool> + Send + Sync>;
@@ -616,6 +619,9 @@ pub struct Services {
/// Which end of the pen is in use, where the platform reports it outside the stylus feed
/// (Windows: the feed carries no pen samples there). Read for the eraser-end tool switch.
pub pen_end: Option<PenEndFn>,
/// Where the pen is on the screen, independent of where the window is (Windows: the pen's
/// pointer messages). A pen moving the window by its title bar follows it (`titlebar`).
pub pen_screen_px: Option<PenScreenFn>,
/// WinTab packet poll (Windows with Windows Ink disabled; see `photocraft_tablet::wintab`).
pub tablet_poll: Option<TabletPollFn>,
/// The persistent brush preset store, loading in the background (desktop; see