// The GTK adapter's input translation, driven directly. // // Four pieces of it are this project's logic and not a toolkit's, and none of them // shows up in a screenshot: the device-pixel mapping GTK's two sizes imply, the // pixel space a drag anchor is kept in, the wheel's unit and sign, and the // modifier mask. A headless X server delivers discrete wheel notches through XTEST // (and only at an integer scale), so the surface branch of `scroll_notches()` and // this session's fractional scales have no event to arrive on -- and the mask // matters because GDK's state carries the mouse-button bits, which would otherwise // ride into the controller's events. // // `gtk_input.hpp` names no GDK type (it mirrors the two scroll-unit values), so // this compiles without GTK and without PLOT_BUILD_GTK4. #include "harness.hpp" #include #include #include using plot::f64; using plot::u32; using plot::gtk4::DeviceMapping; using plot::gtk4::DragAnchor; using plot::gtk4::ScrollUnit; using plot::gtk4::button_from; using plot::gtk4::kSurfacePixelsPerNotch; using plot::gtk4::mods_from; using plot::gtk4::scroll_notches; // GDK's positive `dy` is a scroll *south* (the wheel pushed away from the user) and // a `ScrollUnit::Wheel` delta is already one unit per detent, so a notch up -- // `dy == -1.0`, what an X11 button-4 press arrives as -- has to leave as `+1.0`. // Getting the sign wrong would invert every zoom, which looks like a working plot // with an upside-down wheel. PLOT_TEST(gtk_scroll_wheel_notches_are_already_notches_and_the_sign_flips) { PLOT_CHECK_NEAR(scroll_notches(-1.0, ScrollUnit::Wheel), 1.0); PLOT_CHECK_NEAR(scroll_notches(1.0, ScrollUnit::Wheel), -1.0); PLOT_CHECK_NEAR(scroll_notches(-3.0, ScrollUnit::Wheel), 3.0); PLOT_CHECK_NEAR(scroll_notches(0.0, ScrollUnit::Wheel), 0.0); } // A smooth device reports surface pixels, and GDK defines no notch size for them // ("surface pixels to scroll directly on screen" is its whole description), so the // divisor is this adapter's *calibration* and not a conversion -- see // kSurfacePixelsPerNotch, and note that it is uncalibrated against real hardware. // What a test can hold is the reasoning rather than the number: the constant is // what one notch is, the scaling is linear and unquantised (passing a pixel delta // through unscaled would make a flick of a few hundred pixels tens of notches, a // zoom no gesture can undo as fast as it happens), and the value is chosen so that // a deliberate two-finger gesture -- of the order of 100 surface pixels -- is one // detent rather than several. PLOT_TEST(gtk_scroll_surface_deltas_are_scaled_by_the_calibration) { PLOT_CHECK_NEAR(scroll_notches(-kSurfacePixelsPerNotch, ScrollUnit::Surface), 1.0); PLOT_CHECK_NEAR(scroll_notches(-2.0 * kSurfacePixelsPerNotch, ScrollUnit::Surface), 2.0); PLOT_CHECK_NEAR(scroll_notches(3000.0, ScrollUnit::Surface), -30.0); // The calibration's own assumption, as an invariant that survives a re-tune: a // deliberate gesture of 100 px is at most one detent, never several. PLOT_CHECK(kSurfacePixelsPerNotch >= 100.0); PLOT_CHECK(scroll_notches(-100.0, ScrollUnit::Surface) <= 1.0 + 1e-9); // The sign is the wheel's, and a smooth device's tail is not quantised away: the // scale is a pure division, so a 0.1-pixel event stays a 0.001-notch zoom // (0.015% of the span). What the caller can refuse is an *exactly* zero amount: // no zoom, so the event is not this widget's (see the adapter's `on_scroll`). PLOT_CHECK_NEAR(scroll_notches(+kSurfacePixelsPerNotch, ScrollUnit::Surface), -1.0); PLOT_CHECK_NEAR(scroll_notches(-kSurfacePixelsPerNotch / 1000.0, ScrollUnit::Surface), 0.001, 1e-12); PLOT_CHECK_NEAR(scroll_notches(0.0, ScrollUnit::Surface), 0.0); // The unit decides which path runs: a delta GDK reported in wheel units is one // unit per detent however large it is, and is never divided by the calibration. PLOT_CHECK_NEAR(scroll_notches(-kSurfacePixelsPerNotch, ScrollUnit::Wheel), kSurfacePixelsPerNotch); } // The device-pixel mapping is derived from GTK's two sizes -- the widget's logical // allocation and the device size `GtkGLArea::resize` reports for the viewport its // default handler sets -- and from no scale accessor. The defect this pins: // `gtk_widget_get_scale_factor()` is documented to return "the next higher integer // value" under fractional scaling, so an 800-logical-pixel widget whose viewport // is 1000 device pixels (a 1.25 display) had its input multiplied by 2: a pointer // at logical x=100 was forwarded as 200, 75 device pixels past where the FBO puts // it, and `device_viewport()` claimed 1600 of them. The ratio of the two sizes is // the space the renderer draws into, so it is the one input must use. PLOT_TEST(gtk_device_mapping_is_the_ratio_of_the_two_sizes) { const DeviceMapping quarter = DeviceMapping::from_sizes({800.0f, 600.0f}, {1000.0f, 750.0f}); PLOT_CHECK_NEAR(quarter.factor, 1.25, 1e-6); PLOT_CHECK_NEAR(quarter.viewport.x, 0.0); PLOT_CHECK_NEAR(quarter.viewport.y, 0.0); PLOT_CHECK_NEAR(quarter.viewport.w, 1000.0); PLOT_CHECK_NEAR(quarter.viewport.h, 750.0); PLOT_CHECK_NEAR(quarter.to_device(100.0, 200.0).x, 125.0, 1e-4); PLOT_CHECK_NEAR(quarter.to_device(100.0, 200.0).y, 250.0, 1e-4); const DeviceMapping three_quarters = DeviceMapping::from_sizes({800.0f, 600.0f}, {1400.0f, 1050.0f}); PLOT_CHECK_NEAR(three_quarters.factor, 1.75, 1e-6); PLOT_CHECK_NEAR(three_quarters.viewport.w, 1400.0); PLOT_CHECK_NEAR(three_quarters.viewport.h, 1050.0); PLOT_CHECK_NEAR(three_quarters.to_device(400.0, 0.0).x, 700.0, 1e-4); } // At the integer scales the headless evidence runs at, the derived factor is the // integer one -- 1600/800 is exactly 2, 800/800 exactly 1 -- so the mapping the // previous code built from `gtk_widget_get_scale_factor()` is unchanged there, and // only a fractional scale can tell the two derivations apart. A widget with no // allocation has no ratio to take: it maps 1:1 and reports the (zero) size GTK // gave, which is the state before anything is drawn or any event can arrive. PLOT_TEST(gtk_device_mapping_is_unchanged_at_the_integer_scales) { const DeviceMapping at_1 = DeviceMapping::from_sizes({800.0f, 600.0f}, {800.0f, 600.0f}); PLOT_CHECK(at_1.factor == 1.0f); PLOT_CHECK_NEAR(at_1.to_device(120.0, 40.0).x, 120.0, 1e-6); PLOT_CHECK_NEAR(at_1.to_device(120.0, 40.0).y, 40.0, 1e-6); const DeviceMapping at_2 = DeviceMapping::from_sizes({800.0f, 600.0f}, {1600.0f, 1200.0f}); PLOT_CHECK(at_2.factor == 2.0f); PLOT_CHECK_NEAR(at_2.viewport.w, 1600.0); PLOT_CHECK_NEAR(at_2.to_device(120.0, 40.0).x, 240.0, 1e-6); const DeviceMapping unallocated = DeviceMapping::from_sizes({0.0f, 0.0f}, {0.0f, 0.0f}); PLOT_CHECK(unallocated.factor == 1.0f); PLOT_CHECK_NEAR(unallocated.viewport.w, 0.0); PLOT_CHECK_NEAR(unallocated.viewport.h, 0.0); } // The drag anchor stays *logical* while the factor is re-derived per update: GTK // re-emits `::resize` when the widget's scale-factor property changes, so a drag // can span a scale change. Cached in device pixels, the same gesture reported // `20 * 1.25 - 10 * 1.0 = 15` device pixels of movement where the pointer moved // 12.5, and a position of `100 * 1.0 + 20 * 1.25 = 125` where the FBO puts it at // 150. `Controller::pan` applies such a delta as a fraction of the range span and // clears auto scale, so the phantom 2.5 pixels are a real pan of the plot and a // live axis silently disarmed -- the same defect the Qt adapter's `PointerAnchor` // pins for `QDpi` changes. PLOT_TEST(gtk_drag_anchor_stays_logical_across_a_scale_change) { const DeviceMapping at_1 = DeviceMapping::from_sizes({1000.0f, 800.0f}, {1000.0f, 800.0f}); const DeviceMapping at_125 = DeviceMapping::from_sizes({1000.0f, 800.0f}, {1250.0f, 1000.0f}); PLOT_CHECK(at_1.factor == 1.0f); PLOT_CHECK_NEAR(at_125.factor, 1.25, 1e-6); DragAnchor anchor; anchor.begin(100.0f, 100.0f); PLOT_CHECK(anchor.have); // The press point itself is reported in device pixels at the factor in force. PLOT_CHECK_NEAR(anchor.position(at_1).x, 100.0, 1e-6); PLOT_CHECK_NEAR(anchor.position(at_1).y, 100.0, 1e-6); // First sample, before the move: the pointer moved 10 logical pixels. const DragAnchor::Sample first = anchor.update(10.0, 0.0, at_1); PLOT_CHECK_NEAR(first.delta.x, 10.0, 1e-6); PLOT_CHECK_NEAR(first.position.x, 110.0, 1e-6); // The window is now on the 1.25 display: the same gesture has moved 10 further // logical pixels, so the movement is 10 * 1.25 = 12.5 device pixels and the // position is (100 + 20) * 1.25 = 150, whatever the press was scaled by. const DragAnchor::Sample second = anchor.update(20.0, 0.0, at_125); PLOT_CHECK_NEAR(second.delta.x, 12.5, 1e-6); PLOT_CHECK_NEAR(second.delta.y, 0.0, 1e-6); PLOT_CHECK_NEAR(second.position.x, 150.0, 1e-6); // The release is the press point plus the offset `drag-end` reports, at the // factor in force then -- not the last sample's position, and not an anchor // cached in device pixels. It may be past the last update's offset, which is why // `end()` takes its own. PLOT_CHECK_NEAR(anchor.end(25.0, 0.0, at_125).x, 156.25, 1e-6); PLOT_CHECK(!anchor.have); } // An amount the controller must refuse is not silently turned into a number here: // `Controller::on_wheel` rejects a non-finite delta as a strict no-op, and that // decision belongs to it alone. PLOT_TEST(gtk_scroll_passes_a_non_finite_delta_through) { PLOT_CHECK(std::isnan(scroll_notches(std::nan(""), ScrollUnit::Wheel))); PLOT_CHECK(std::isnan(scroll_notches(std::nan(""), ScrollUnit::Surface))); } // GDK reports the button bits inside the same state word as the modifiers -- a // release arrives with `GDK_BUTTON1_MASK` (1 << 8) set, a right-drag with `1 << 10` // -- so the translation is a mask. Without it every drag's events would carry bits // no other adapter's do. PLOT_TEST(gtk_mods_ignore_the_mouse_button_bits) { PLOT_CHECK(mods_from(0x100u) == 0u); // GDK_BUTTON1_MASK PLOT_CHECK(mods_from(0x400u) == 0u); // GDK_BUTTON3_MASK PLOT_CHECK(mods_from(0x501u) == static_cast(plot::input::Mod::Shift)); const u32 shift = static_cast(plot::input::Mod::Shift); const u32 ctrl = static_cast(plot::input::Mod::Ctrl); const u32 alt = static_cast(plot::input::Mod::Alt); const u32 super = static_cast(plot::input::Mod::Super); PLOT_CHECK(mods_from(1u) == shift); // GDK_SHIFT_MASK PLOT_CHECK(mods_from(4u) == ctrl); // GDK_CONTROL_MASK PLOT_CHECK(mods_from(8u) == alt); // GDK_ALT_MASK PLOT_CHECK(mods_from(1u << 26) == super); // GDK_SUPER_MASK PLOT_CHECK(mods_from(1u << 28) == super); // GDK_META_MASK, Command on macOS PLOT_CHECK(mods_from(1u | 4u | 8u | (1u << 26)) == (shift | ctrl | alt | super)); // GDK_LOCK_MASK (CapsLock) is not a modifier the controller names. PLOT_CHECK(mods_from(2u) == 0u); } // The gestures listen to "any button", so the numbers that reach the mapping are // 1, 2, 3 and whatever the device reports. Only the three the controller has a // gesture for are accepted: an unhandled button is dropped rather than defaulting // to a left-drag pan. PLOT_TEST(gtk_buttons_map_to_the_controllers_three_or_to_nothing) { PLOT_CHECK(button_from(1u) == std::optional(plot::input::Button::Left)); PLOT_CHECK(button_from(2u) == std::optional(plot::input::Button::Middle)); PLOT_CHECK(button_from(3u) == std::optional(plot::input::Button::Right)); PLOT_CHECK(!button_from(0u).has_value()); PLOT_CHECK(!button_from(8u).has_value()); // a navigation button PLOT_CHECK(!button_from(9u).has_value()); }