diff --git a/devel/0152.md b/devel/0152.md new file mode 100644 index 0000000000..0365d28f46 --- /dev/null +++ b/devel/0152.md @@ -0,0 +1,291 @@ +# Okular PDF 阅读器"小手"(Browse Tool)功能调研 + +## 一、功能概述 + +Okular 打开 PDF 后,默认处于 **Browse(浏览)模式**,此时鼠标在页面上显示为**张开的小手**(`Qt::OpenHandCursor`)。这是 Okular 最核心的浏览交互方式,允许用户通过鼠标拖动来平移/滚动文档页面。 + +## 二、光标状态机 + +在 Browse 模式下,光标会根据当前状态动态变化: + +| 状态 | 光标 | 触发条件 | +|------|------|----------| +| 张开小手 | `OpenHandCursor` | 默认状态,鼠标悬停在页面内容区域且未按下 | +| 握紧小手 | `ClosedHandCursor` | 按下左键并拖动页面时 | +| 手指/指点手 | `PointingHandCursor` | 悬停在超链接、注释交互区域上时 | +| 箭头 | `ArrowCursor` | 鼠标离开页面内容区域(如空白处)时 | + +**关键实现位置**:`part/pageview.cpp` 的 `updateCursor()` 函数(约 4295 行)。 + +- 当 `QScroller` 状态为 `Pressed` 或 `Dragging` 时,强制显示 `ClosedHandCursor` +- 当检测到 `ObjectRect::Action`(链接)时,显示 `PointingHandCursor` +- 否则在 Browse 模式下回退到 `OpenHandCursor` + +## 三、拖动滚动的技术实现 + +### 3.1 QScroller 驱动 + +Okular 使用 Qt 的 `QScroller` 来实现平滑的拖动滚动,而非直接操作滚动条。 + +**初始化配置**(`part/pageview.cpp` 约 407~416 行): + +```cpp +d->scroller = QScroller::scroller(viewport()); +QScrollerProperties prop; +prop.setScrollMetric(QScrollerProperties::DecelerationFactor, 0.3); +prop.setScrollMetric(QScrollerProperties::MaximumVelocity, 1); +prop.setScrollMetric(QScrollerProperties::AcceleratingFlickMaximumTime, 0.2); +prop.setScrollMetric(QScrollerProperties::HorizontalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff); +prop.setScrollMetric(QScrollerProperties::VerticalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff); +prop.setScrollMetric(QScrollerProperties::DragStartDistance, 0.0); +d->scroller->setScrollerProperties(prop); +``` + +**产品细节**: +- `DragStartDistance = 0.0`:按下即开始拖动,无需最小拖动距离阈值 +- `DecelerationFactor = 0.3`:松开后惯性滚动减速较快 +- `MaximumVelocity = 1`:限制最大滚动速度 +- `OvershootAlwaysOff`:滚动到边界时没有回弹(overshoot)效果 + +### 3.2 鼠标事件处理流程 + +**按下左键**(`mousePressEvent`,约 2476 行): +1. 如果当前有 Annotation 处于活动状态,优先处理 Annotation 事件 +2. 调用 `QScroller::handleInput(InputPress, pos, timestamp)` 开始拖动会话 +3. 启动 `leftClickTimer`(双击间隔 + 10ms),用于区分单击和拖动 + +**移动鼠标**(`mouseMoveEvent`,约 2332 行): +1. 如果左键按住且 Annotation 未激活: + - 设置光标为 `ClosedHandCursor` + - 调用 `QScroller::handleInput(InputMove, pos + offset, timestamp)` 更新滚动位置 + - 如果启用了屏幕边缘环绕,通过 `CursorWrapHelper::wrapCursor()` 处理光标环绕 +2. 如果右键按住并移动超过 5px:自动切换到矩形选择模式(`RectSelect`) + +**释放左键**(`mouseReleaseEvent`,约 2693 行): +1. 调用 `QScroller::handleInput(InputRelease, pos, timestamp)` 结束拖动 +2. 如果光标当前是 `ClosedHandCursor`,调用 `updateCursor()` 恢复为 `OpenHandCursor` +3. 如果鼠标未移动(`manhattanLength < startDragDistance`),视为单击,可能触发链接跳转或页面切换 + +## 四、屏幕边缘光标环绕(Cursor Wrap) + +Okular 提供了一个非常细致的功能:**当使用 Browse Tool 拖动页面时,光标到达屏幕边缘后会自动环绕到对侧边缘**,让用户可以无限拖动而不受屏幕边界限制。 + +### 4.1 用户设置 + +在"首选项 → 常规"中有一个复选框: +> "When using Browse Tool, wrap cursor at screen edges" + +对应代码:`part/dlggeneral.cpp` 约 194~197 行,设置项 `kcfg_DragBeyondScreenEdges`。 + +### 4.2 实现机制 + +**核心类**:`CursorWrapHelper`(`part/cursorwraphelper.cpp`) + +实现细节: +1. 当光标接近屏幕边缘(距离边缘 4px 以内)时,将光标瞬移到对侧边缘(减去 10px 偏移) +2. 通过比较两次调用之间的光标移动幅度(magnitude 在 0.5~2.0 之间),判断是否为环绕操作 +3. 如果是环绕,返回环绕的位移量,由调用方累加到 `mouseGrabOffset` +4. `startDrag()` 方法在开始新的拖动时重置环绕状态 + +**页面视图中的应用**(`pageview.cpp` 约 2352 行): +- 可以上下左右四个方向环绕 +- 环绕条件:对应方向的滚动条未达到极限值(例如顶部环绕只在 `verticalScrollBar()->value() < maximum()` 时启用) + +**缩略图列表中的应用**(`thumbnaillist.cpp` 约 898 行): +- 只允许上下方向环绕 +- 环绕后重置 `m_mouseGrabPos` + +## 五、缩略图面板的小手交互 + +Okular 的左侧缩略图面板也使用了小手光标,但交互逻辑与主视图不同: + +**光标状态**: +- 悬停在缩略图上的页面预览区域(可见区域矩形内):`OpenHandCursor` +- 悬停在缩略图外部(标签区域):`ArrowCursor` +- 按下拖动时:`ClosedHandCursor` + +**交互行为**: +1. **单击缩略图**:跳转到对应页面,并将点击位置居中显示在视口中 +2. **在缩略图上拖动**:可以连续翻页。当拖动越过当前缩略图边界时,自动切换到相邻页面 +3. 缩略图上的黄色半透明矩形表示当前主视口可见的区域 + +## 六、拖动与点击的精确区分 + +Okular 在 Browse 模式下需要精确区分"拖动页面"和"点击触发动作": + +**判定逻辑**(`pageview.cpp` 约 2708 行): +```cpp +if (leftButton && pageItem && pageItem == pageItemPressPos + && ((d->mousePressPos - e->globalPosition()).manhattanLength() < QApplication::startDragDistance())) { + // 视为单击,处理链接跳转、Shift+点击查找源引用等 +} +``` + +- 使用 Qt 的全局拖放阈值 `QApplication::startDragDistance()`(通常是 4px) +- 只有在按下和释放时的页面项相同,才视为有效的单击 +- 如果移动距离超过阈值,则视为拖动,不会触发链接跳转 + +## 七、自动滚动(Auto-scroll) + +在进行文本选择或矩形选择时,如果鼠标移出视口边界,Okular 会自动滚动视图以跟随鼠标: + +**实现机制**:`scrollPosIntoView()` 函数(`pageview.cpp` 约 3864 行) +- 计算鼠标位置与视口边缘的距离 +- 阻尼系数为 6,滚动速度 = 距离 / 6 +- 使用 `dragScrollTimer` 以 **60fps** 的频率持续滚动 +- 滚动方向:水平/垂直,根据鼠标超出哪一侧边界决定 + +## 八、与其他鼠标模式的对比 + +Okular 提供多种鼠标模式,通过工具栏或快捷键切换(`Ctrl+1`~`Ctrl+6`): + +| 模式 | 快捷键 | 光标 | 功能 | +|------|--------|------|------| +| Browse | Ctrl+1 | 小手 | 拖动滚动、点击链接 | +| Zoom | Ctrl+2 | 十字/默认 | 框选放大,右键缩小 | +| RectSelect | Ctrl+3 | 十字 | 矩形区域选择 | +| TextSelect | Ctrl+4 | IBeam | 文本选择 | +| TableSelect | Ctrl+5 | 默认 | 表格选择/复制 | +| Magnifier | Ctrl+6 | 十字 | 放大镜 | + +模式之间通过 `QActionGroup` 互斥切换,当前模式保存到 `Okular::Settings::mouseMode()` 中。 + +## 九、Presentation 模式中的手型光标 + +在演示模式(Presentation Mode)中,也有一个 `m_handCursor` 布尔标志: + +- 当鼠标悬停在链接、电影注释或富媒体注释上时,显示 `PointingHandCursor` +- 其他情况下显示普通箭头 +- 使用 `needsHandCursor` 变量统一管理,避免频繁设置光标导致的闪烁 + +## 十、总结:产品细节要点 + +1. **零延迟响应**:`DragStartDistance = 0`,按下即开始拖动,没有启动阈值 +2. **双态小手**:张开表示"可拖动",握紧表示"正在拖动",给用户明确的交互反馈 +3. **无限拖动**:通过屏幕边缘光标环绕,用户可以在小屏幕上无限平移大页面 +4. **智能切换**:右键拖动超过 5px 自动切换到选择模式,减少用户操作步骤 +5. **点击保护**:通过曼哈顿距离精确区分拖动和点击,避免误触链接 +6. **惯性滚动**:松开后页面会继续滑动一段距离(由 QScroller 处理) +7. **同步机制**:QScroller 与滚动条保持同步,确保所有输入方式(触摸板、鼠标、滚动条)行为一致 + +## 十一、How:在 Mogan 中实现小手功能 + +### 11.1 目标代码位置 + +- **头文件**:`src/Plugins/Qt/qt_pdf_reader_widget.hpp` +- **实现文件**:`src/Plugins/Qt/qt_pdf_reader_widget.cpp` +- **测试文件**:`tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp` + +### 11.2 实现思路 + +Mogan 的 `PDFReaderWidget` 使用 `QScrollArea` 作为滚动容器,内部 `contentWidget_` 垂直排列所有页面 `QLabel`。当前仅支持矩形选择模式(`rectSelectMode_`),需要在非选择模式下默认启用 Okular 式的 Browse(小手拖动)模式。 + +#### 状态变量 + +在 `PDFReaderWidget` 中添加以下成员: + +```cpp +bool browseDragging_; // 是否正在小手拖动中 +QPoint browseDragStartPos_; // 拖动开始时鼠标的全局位置 +int browseDragStartY_; // 拖动开始时垂直滚动条的值 +bool browseDragActive_; // 是否已越过点击/拖动阈值,确认为拖动 +``` + +#### 光标状态机 + +在 `eventFilter` 的 `QEvent::MouseButtonPress` 和 `QEvent::MouseMove` 中维护光标: + +| 场景 | 光标 | +|------|------| +| `rectSelectMode_ == true` | `CrossCursor` | +| `rectSelectMode_ == false`,未按下 | `OpenHandCursor` | +| `rectSelectMode_ == false`,左键按住且已确认为拖动 | `ClosedHandCursor` | + +**关键**:在构造函数中初始化时即设置 `scrollArea_->viewport()->setCursor(Qt::OpenHandCursor)`,并在 `onRectSelectToggled` 中根据模式切换光标。 + +#### 鼠标事件处理 + +在 `eventFilter` 中,当 `!rectSelectMode_` 时拦截 `MouseButtonPress`、`MouseMove`、`MouseButtonRelease`: + +**MouseButtonPress(左键)**: +1. 记录 `browseDragStartPos_ = mouseEvent->globalPosition().toPoint()` +2. 记录 `browseDragStartY_ = verticalScrollBar()->value()` +3. 重置 `browseDragActive_ = false`,`lastMoveTimestamp_ = 0` +4. `browseDragging_ = true` +5. **立即显示 `ClosedHandCursor`**(零延迟反馈) +6. 停止可能正在运行的惯性滚动定时器 +7. 消耗事件 + +**MouseMove(左键按住)**: +1. **零延迟响应**:无论移动多少距离,直接计算新滚动位置并更新 + - `deltaY = mouseEvent->globalPosition().toPoint().y() - browseDragStartPos_.y()` + - 新滚动位置 = `browseDragStartY_ - deltaY`(grab-and-pull) + - `verticalScrollBar()->setValue(newValue)` +2. 如果距离超过 `QApplication::startDragDistance()`,设置 `browseDragActive_ = true` + - `browseDragActive_` 仅用于区分「单击」vs「拖动」,不再阻止滚动更新 +3. 实时采集速度用于惯性滚动 +4. 消耗事件 + +**MouseButtonRelease(左键)**: +1. `browseDragging_ = false` +2. 恢复光标为 `OpenHandCursor` +3. 如果 `browseDragActive_` 为 true 且释放速度超过阈值,启动惯性滚动 +4. 消耗事件 + +#### 惯性滚动(Inertial Scroll) + +Okular 松开后页面会继续滑动一段距离(由 `QScroller` 的 `DecelerationFactor` 处理)。在 Mogan 中手动实现: + +**状态变量**: +- `inertialTimer_`:QTimer,间隔 16ms(约 60fps) +- `inertialVelocityY_`:释放瞬间的 Y 方向速度(px/ms) +- `lastMovePos_` / `lastMoveTimestamp_`:用于在 mouseMove 中实时计算速度 + +**速度采集**(`mouseMove` 中): +```cpp +qint64 dt = currentTs - lastMoveTimestamp_; +if (dt > 0) { + inertialVelocityY_ = (currentPos.y() - lastMovePos_.y()) / dt; +} +``` + +**启动惯性滚动**(`mouseRelease` 中): +- 速度阈值 `|velocity| > 0.5 px/ms`(约 30px/60ms)才启动 +- 调用 `inertialTimer_->start()` + +**惯性滚动槽函数**(`onInertialScroll`,每 16ms 触发): +1. 新滚动位置 = `currentValue - round(velocity * 16)` +2. 速度衰减:`velocity *= 0.92`(每帧衰减 8%) +3. 停止条件:`|velocity| < 0.15 px/ms` + +#### 点击保护 + +使用 Qt 全局阈值 `QApplication::startDragDistance()`(通常 4px)精确区分单击和拖动: +- 只有在 `mouseMove` 中移动距离超过阈值后才将 `browseDragActive_` 设为 true +- `mouseRelease` 时若 `browseDragActive_` 仍为 false,则视为单击 + +#### 日志调试 + +在关键位置使用 `#ifdef LIII_DEBUG` 包裹 `cout` 输出,例如: +- 拖动开始时输出当前滚动位置 +- `browseDragActive_` 变为 true 时输出确认信息 +- 释放时输出最终滚动位置和速度 +- 惯性滚动每帧输出当前位置和速度 +- 惯性滚动停止时输出确认信息 + +### 11.3 测试策略(TDD) + +1. **测试默认光标**:加载 PDF 后验证 `viewport()->cursor().shape() == Qt::OpenHandCursor` +2. **测试拖动滚动**:模拟鼠标按下 → 向下移动 30px → 释放,验证滚动条 `value()` 减小(grab-and-pull,页面向下移动) +3. **测试拖动光标**:模拟按下后移动超过阈值,验证光标变为 `ClosedHandCursor` +4. **测试释放恢复**:释放后验证光标恢复为 `OpenHandCursor` +5. **测试点击不滚动**:短距离按下释放(2px),验证滚动条值不变 +6. **测试选择模式覆盖**:进入 `rectSelectMode` 后验证光标为 `CrossCursor`,且小手拖动逻辑不触发 +7. **测试惯性滚动启动**:快速拖动后释放,等待 80ms,验证滚动条值与释放时不同 +8. **测试惯性滚动停止**:等待 600ms,验证滚动条值稳定不再变化 + +### 11.4 与现有功能的兼容 + +- 保留现有的 `rectSelectMode_` 及 `eventFilter` 中矩形选择相关的事件处理 +- 在 `eventFilter` 中,矩形选择的事件分支应优先于小手拖动分支(即 `rectSelectMode_` 为 true 时直接走原有逻辑) +- 保持 `wheelEvent`(Ctrl+滚轮缩放)和 `KeyPress` 事件处理不变 diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.cpp b/src/Plugins/Qt/qt_pdf_reader_widget.cpp index ed5165fe9b..80645cd50d 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.cpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.cpp @@ -42,9 +42,10 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) zoomCombo_ (nullptr), prevPageBtn_ (nullptr), pageEdit_ (nullptr), pageTotalLabel_ (nullptr), nextPageBtn_ (nullptr), zoomInBtn_ (nullptr), rectSelectBtn_ (nullptr), rubberBand_ (nullptr), rectSelectMode_ (false), - rectSelectDragging_ (false), hintLabel_ (nullptr), pageCount_ (0), - hasError_ (false), targetDpi_ (DEFAULT_DPI), zoomFactor_ (1.0), - pageAspectRatio_ (0.0), pageBaseWidthPts_ (0.0), + rectSelectDragging_ (false), hintLabel_ (nullptr), + browseDragging_ (false), browseDragActive_ (false), scroller_ (nullptr), + pageCount_ (0), hasError_ (false), targetDpi_ (DEFAULT_DPI), + zoomFactor_ (1.0), pageAspectRatio_ (0.0), pageBaseWidthPts_ (0.0), zoomDebounceTimer_ (nullptr), resizeDebounceTimer_ (nullptr) { mainLayout_= new QVBoxLayout (this); @@ -68,6 +69,36 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) scrollArea_->setWidget (contentWidget_); scrollArea_->viewport ()->installEventFilter (this); + scrollArea_->viewport ()->setCursor (Qt::OpenHandCursor); + + // QScroller 配置(参考 Okular) + scroller_= QScroller::scroller (scrollArea_->viewport ()); + QScrollerProperties prop; + prop.setScrollMetric (QScrollerProperties::DecelerationFactor, 0.3); + prop.setScrollMetric (QScrollerProperties::MaximumVelocity, 1.0); + prop.setScrollMetric (QScrollerProperties::AcceleratingFlickMaximumTime, 0.2); + prop.setScrollMetric (QScrollerProperties::HorizontalOvershootPolicy, + QScrollerProperties::OvershootAlwaysOff); + prop.setScrollMetric (QScrollerProperties::VerticalOvershootPolicy, + QScrollerProperties::OvershootAlwaysOff); + prop.setScrollMetric (QScrollerProperties::DragStartDistance, 0.0); + scroller_->setScrollerProperties (prop); + + // 保持与 QScrollArea 内部一致的步长(Okular 同款 magic value) + scrollArea_->verticalScrollBar ()->setSingleStep (20); + scrollArea_->horizontalScrollBar ()->setSingleStep (20); + + // 滚动条与 QScroller 同步(Okular 同款) + auto syncScroller= [this] () { + QScrollBar* hbar= scrollArea_->horizontalScrollBar (); + QScrollBar* vbar= scrollArea_->verticalScrollBar (); + scroller_->scrollTo (QPoint (hbar->value (), vbar->value ()), 0); + }; + connect (scrollArea_->verticalScrollBar (), &QAbstractSlider::actionTriggered, + this, syncScroller, Qt::QueuedConnection); + connect (scrollArea_->horizontalScrollBar (), + &QAbstractSlider::actionTriggered, this, syncScroller, + Qt::QueuedConnection); mainLayout_->addWidget (scrollArea_); @@ -390,7 +421,7 @@ PDFReaderWidget::onRectSelectToggled (bool checked) { if (scrollArea_ && scrollArea_->viewport ()) { QWidget* vp= scrollArea_->viewport (); vp->setMouseTracking (rectSelectMode_); - vp->setCursor (rectSelectMode_ ? Qt::CrossCursor : Qt::ArrowCursor); + vp->setCursor (rectSelectMode_ ? Qt::CrossCursor : Qt::OpenHandCursor); } if (!rectSelectMode_ && rubberBand_) { rubberBand_->hide (); @@ -1039,6 +1070,61 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { resizeDebounceTimer_->start (); } } + // ============================================================ + // Browse (hand) tool: default drag-to-scroll behavior + // ============================================================ + else if (!rectSelectMode_ && event->type () == QEvent::MouseButtonPress) { + QMouseEvent* mouseEvent= static_cast (event); + if (mouseEvent->button () == Qt::LeftButton) { + browseDragging_ = true; + browseDragActive_ = false; + browseDragStartPos_= mouseEvent->globalPosition ().toPoint (); + scroller_->handleInput (QScroller::InputPress, mouseEvent->pos (), + mouseEvent->timestamp ()); + scrollArea_->viewport ()->setCursor (Qt::ClosedHandCursor); +#ifdef LIII_DEBUG + cout << "Browse press at " << mouseEvent->pos ().x () << "," + << mouseEvent->pos ().y () << "\n"; +#endif + mouseEvent->accept (); + return true; + } + } + else if (!rectSelectMode_ && browseDragging_ && + event->type () == QEvent::MouseMove) { + QMouseEvent* mouseEvent= static_cast (event); + int delta= + (mouseEvent->globalPosition ().toPoint () - browseDragStartPos_) + .manhattanLength (); + if (!browseDragActive_ && delta > QApplication::startDragDistance ()) { + browseDragActive_= true; +#ifdef LIII_DEBUG + cout << "Browse drag activated, delta=" << delta << "\n"; +#endif + } + scroller_->handleInput (QScroller::InputMove, mouseEvent->pos (), + mouseEvent->timestamp ()); + mouseEvent->accept (); + return true; + } + else if (!rectSelectMode_ && browseDragging_ && + event->type () == QEvent::MouseButtonRelease) { + QMouseEvent* mouseEvent= static_cast (event); + if (mouseEvent->button () == Qt::LeftButton) { + browseDragging_= false; + scrollArea_->viewport ()->setCursor (Qt::OpenHandCursor); + scroller_->handleInput (QScroller::InputRelease, mouseEvent->pos (), + mouseEvent->timestamp ()); +#ifdef LIII_DEBUG + cout << "Browse release, wasDrag=" << browseDragActive_ << "\n"; +#endif + mouseEvent->accept (); + return true; + } + } + // ============================================================ + // Rectangular selection mode + // ============================================================ else if (rectSelectMode_ && event->type () == QEvent::MouseButtonPress) { QMouseEvent* mouseEvent= static_cast (event); if (mouseEvent->button () == Qt::LeftButton) { diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.hpp b/src/Plugins/Qt/qt_pdf_reader_widget.hpp index 972f6fdc51..7f46e6581e 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.hpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.hpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -79,7 +80,6 @@ private slots: void onPageEditingFinished (); void updatePageNavigation (); void onRectSelectToggled (bool checked); - void keyPressEvent (QKeyEvent* event) override; private: @@ -116,6 +116,12 @@ private slots: bool rectSelectDragging_; QLabel* hintLabel_; + // Browse (hand) tool state + bool browseDragging_; + QPoint browseDragStartPos_; + bool browseDragActive_; + QScroller* scroller_; + QByteArray pdfData_; int pageCount_; bool hasError_; diff --git a/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp b/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp index d7cf8f70eb..8a57a1fe37 100644 --- a/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp +++ b/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp @@ -218,13 +218,13 @@ private slots: QWidget* vp= widget->viewport (); QVERIFY (vp != nullptr); - QCOMPARE (vp->cursor ().shape (), Qt::ArrowCursor); + QCOMPARE (vp->cursor ().shape (), Qt::OpenHandCursor); rectBtn->click (); QApplication::processEvents (); QCOMPARE (vp->cursor ().shape (), Qt::CrossCursor); rectBtn->click (); QApplication::processEvents (); - QCOMPARE (vp->cursor ().shape (), Qt::ArrowCursor); + QCOMPARE (vp->cursor ().shape (), Qt::OpenHandCursor); delete widget; } @@ -340,7 +340,7 @@ private slots: QApplication::processEvents (); QVERIFY (!widget->isRectSelectMode ()); - QCOMPARE (vp->cursor ().shape (), Qt::ArrowCursor); + QCOMPARE (vp->cursor ().shape (), Qt::OpenHandCursor); delete widget; } @@ -385,7 +385,281 @@ private slots: QApplication::processEvents (); QVERIFY (!widget->isRectSelectMode ()); - QCOMPARE (vp->cursor ().shape (), Qt::ArrowCursor); + QCOMPARE (vp->cursor ().shape (), Qt::OpenHandCursor); + + delete widget; + } + + // ============================================================ + // Browse (Hand) Tool Tests + // ============================================================ + + void test_defaultCursorIsOpenHand () { + PDFReaderWidget* widget= new PDFReaderWidget (); + widget->resize (400, 300); + widget->show (); + QApplication::processEvents (); + + QWidget* vp= widget->viewport (); + QVERIFY (vp != nullptr); + QCOMPARE (vp->cursor ().shape (), Qt::OpenHandCursor); + delete widget; + } + + void test_dragScrollsDown () { + PDFReaderWidget* widget= new PDFReaderWidget (); + widget->resize (200, 100); + widget->show (); + + url pdfUrl= url_system ("$TEXMACS_PATH/tests/PDF/pdf_1_4_sample.pdf"); + if (is_regular (pdfUrl)) { + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + } + QApplication::processEvents (); + + QScrollBar* vbar= widget->verticalScrollBar (); + QVERIFY (QTest::qWaitFor ([&] () { return vbar->maximum () > 0; }, 1000)); + + QWidget* vp= widget->viewport (); + QVERIFY (vp != nullptr); + + // 先将滚动条设到中间位置,以便双向验证 + int midPos= vbar->maximum () / 2; + vbar->setValue (midPos); + QApplication::processEvents (); + int initialPos= vbar->value (); + + // 模拟向下拖动 30px(grab-and-pull:页面向下移动,滚动条值减小) + QPoint start (100, 100); + QPoint end (100, 130); + QTest::mousePress (vp, Qt::LeftButton, Qt::NoModifier, start); + QTest::mouseMove (vp, end); + QTest::mouseRelease (vp, Qt::LeftButton, Qt::NoModifier, end); + QApplication::processEvents (); + + int newPos= vbar->value (); + QVERIFY (newPos < initialPos); + delete widget; + } + + void test_dragCursorChangesToClosedHand () { + PDFReaderWidget* widget= new PDFReaderWidget (); + widget->resize (400, 300); + widget->show (); + + url pdfUrl= url_system ("$TEXMACS_PATH/tests/PDF/pdf_1_4_sample.pdf"); + if (is_regular (pdfUrl)) { + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + } + QApplication::processEvents (); + + QWidget* vp= widget->viewport (); + QVERIFY (vp != nullptr); + QCOMPARE (vp->cursor ().shape (), Qt::OpenHandCursor); + + QPoint start (100, 100); + QTest::mousePress (vp, Qt::LeftButton, Qt::NoModifier, start); + QApplication::processEvents (); + + // 零延迟响应:按下后立即显示 ClosedHandCursor + QCOMPARE (vp->cursor ().shape (), Qt::ClosedHandCursor); + + QPoint beyondThreshold ( + start.x (), start.y () + QApplication::startDragDistance () + 2); + QTest::mouseMove (vp, beyondThreshold); + QApplication::processEvents (); + + // 拖动过程中保持 ClosedHandCursor + QCOMPARE (vp->cursor ().shape (), Qt::ClosedHandCursor); + + QTest::mouseRelease (vp, Qt::LeftButton, Qt::NoModifier, beyondThreshold); + QApplication::processEvents (); + delete widget; + } + + void test_releaseRestoresOpenHand () { + PDFReaderWidget* widget= new PDFReaderWidget (); + widget->resize (400, 300); + widget->show (); + + url pdfUrl= url_system ("$TEXMACS_PATH/tests/PDF/pdf_1_4_sample.pdf"); + if (is_regular (pdfUrl)) { + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + } + QApplication::processEvents (); + + QWidget* vp= widget->viewport (); + QVERIFY (vp != nullptr); + + QPoint start (100, 100); + QPoint end (100, 140); + QTest::mousePress (vp, Qt::LeftButton, Qt::NoModifier, start); + QTest::mouseMove (vp, end); + QApplication::processEvents (); + QCOMPARE (vp->cursor ().shape (), Qt::ClosedHandCursor); + + QTest::mouseRelease (vp, Qt::LeftButton, Qt::NoModifier, end); + QApplication::processEvents (); + QCOMPARE (vp->cursor ().shape (), Qt::OpenHandCursor); + delete widget; + } + + void test_clickDoesNotScroll () { + PDFReaderWidget* widget= new PDFReaderWidget (); + widget->resize (200, 100); + widget->show (); + + url pdfUrl= url_system ("$TEXMACS_PATH/tests/PDF/pdf_1_4_sample.pdf"); + if (is_regular (pdfUrl)) { + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + } + QApplication::processEvents (); + + QScrollBar* vbar= widget->verticalScrollBar (); + QVERIFY (QTest::qWaitFor ([&] () { return vbar->maximum () > 0; }, 1000)); + + QWidget* vp= widget->viewport (); + QVERIFY (vp != nullptr); + + int initialPos= vbar->value (); + + // 零延迟响应下,任何移动都会滚动; + // 只有完全不动地按下释放才算单击,不触发滚动 + QPoint start (100, 100); + QTest::mousePress (vp, Qt::LeftButton, Qt::NoModifier, start); + QTest::mouseRelease (vp, Qt::LeftButton, Qt::NoModifier, start); + QApplication::processEvents (); + + QCOMPARE (vbar->value (), initialPos); + delete widget; + } + + void test_rectSelectModeOverridesHand () { + PDFReaderWidget* widget= new PDFReaderWidget (); + widget->resize (400, 300); + widget->show (); + + url pdfUrl= url_system ("$TEXMACS_PATH/tests/PDF/pdf_1_4_sample.pdf"); + if (is_regular (pdfUrl)) { + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + } + QApplication::processEvents (); + + QToolButton* rectBtn= + widget->findChild ("pdf-screenshot-btn"); + QVERIFY (rectBtn != nullptr); + + QWidget* vp= widget->viewport (); + QVERIFY (vp != nullptr); + + // 默认小手模式 + QCOMPARE (vp->cursor ().shape (), Qt::OpenHandCursor); + + // 进入选择模式 + rectBtn->click (); + QApplication::processEvents (); + QCOMPARE (vp->cursor ().shape (), Qt::CrossCursor); + + // 在选择模式下点击不应触发小手拖动 + QScrollBar* vbar= widget->verticalScrollBar (); + QVERIFY (QTest::qWaitFor ([&] () { return vbar->maximum () > 0; }, 1000)); + int initialPos= vbar->value (); + + QPoint start (50, 50); + QPoint end (50, 150); + QTest::mousePress (vp, Qt::LeftButton, Qt::NoModifier, start); + QTest::mouseMove (vp, end); + QTest::mouseRelease (vp, Qt::LeftButton, Qt::NoModifier, end); + QApplication::processEvents (); + + // 选择模式下是 rubber band 选择,滚动条不应因小手拖动而变化 + // 但 rubber band 操作本身不滚动,所以值应保持不变 + QCOMPARE (vbar->value (), initialPos); + + // 退出选择模式后恢复小手 + rectBtn->click (); + QApplication::processEvents (); + QCOMPARE (vp->cursor ().shape (), Qt::OpenHandCursor); + + delete widget; + } + + void test_inertialScrollAfterRelease () { + PDFReaderWidget* widget= new PDFReaderWidget (); + widget->resize (200, 100); + widget->show (); + + url pdfUrl= url_system ("$TEXMACS_PATH/tests/PDF/pdf_1_4_sample.pdf"); + if (is_regular (pdfUrl)) { + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + } + QApplication::processEvents (); + + QScrollBar* vbar= widget->verticalScrollBar (); + QVERIFY (QTest::qWaitFor ([&] () { return vbar->maximum () > 0; }, 1000)); + + QWidget* vp= widget->viewport (); + QVERIFY (vp != nullptr); + + // 先将滚动条设到中间,避免触顶/底 + int midPos= vbar->maximum () / 2; + vbar->setValue (midPos); + QApplication::processEvents (); + + // 快速向下拖动 50px(多步模拟高速运动) + QPoint start (50, 50); + QTest::mousePress (vp, Qt::LeftButton, Qt::NoModifier, start); + for (int i= 1; i <= 5; ++i) { + QTest::mouseMove (vp, QPoint (50, 50 + i * 10)); + QApplication::processEvents (); + } + QTest::mouseRelease (vp, Qt::LeftButton, Qt::NoModifier, QPoint (50, 100)); + int releasePos= vbar->value (); + + // 释放后等待一小段时间,惯性滚动应使值继续变化 + QTest::qWait (80); + int afterInertia= vbar->value (); + QVERIFY (afterInertia != releasePos); + + delete widget; + } + + void test_inertialScrollStopsEventually () { + PDFReaderWidget* widget= new PDFReaderWidget (); + widget->resize (200, 100); + widget->show (); + + url pdfUrl= url_system ("$TEXMACS_PATH/tests/PDF/pdf_1_4_sample.pdf"); + if (is_regular (pdfUrl)) { + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + } + QApplication::processEvents (); + + QScrollBar* vbar= widget->verticalScrollBar (); + QVERIFY (QTest::qWaitFor ([&] () { return vbar->maximum () > 0; }, 1000)); + + QWidget* vp= widget->viewport (); + QVERIFY (vp != nullptr); + + int midPos= vbar->maximum () / 2; + vbar->setValue (midPos); + QApplication::processEvents (); + + QPoint start (50, 50); + QTest::mousePress (vp, Qt::LeftButton, Qt::NoModifier, start); + for (int i= 1; i <= 5; ++i) { + QTest::mouseMove (vp, QPoint (50, 50 + i * 10)); + QApplication::processEvents (); + } + QTest::mouseRelease (vp, Qt::LeftButton, Qt::NoModifier, QPoint (50, 100)); + + // 等待足够长的时间让惯性滚动完全停止 + QTest::qWait (600); + int stablePos= vbar->value (); + + // 再等待一帧,值应不再变化 + QTest::qWait (50); + QCOMPARE (vbar->value (), stablePos); delete widget; }