Skip to content
291 changes: 291 additions & 0 deletions devel/0152.md
Original file line number Diff line number Diff line change
@@ -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` 事件处理不变
Loading
Loading