Pad/doc/touch.md

520 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Touch and Input Handling
This document specifies how Pad handles low-level pointer and keyboard events using Gioui primitives, and how these are translated into editor actions (cursor movement, selection, scrolling).
## 1. Gioui Primitives
Pad avoids high-level widgets and uses the following low-level Gioui ops and events. Ops are submitted in the paint function; Events are returned by `w.Event()`.
| Op (submitted in paint) | Event (returned by w.Event()) | Use Case |
|---|---|---|
| `pointer.InputOp` | `pointer.Event` | Defines hit-regions (editor, buttons, search bar). Captures `Press`, `Release`, `Move`, `Drag`, `Scroll`. |
| `key.InputOp` | `key.Event` | Enables keyboard focus. Captures `Edit` (text insertion), `Press` (Backspace, Enter, Arrows). |
| `key.FocusOp` | — | Requests keyboard focus for the active element. |
| `key.SoftKeyboardOp` | — | Explicitly shows/hides the Android soft keyboard. |
### 1.1 Op/Event Flow
Because Logic does not have access to `*op.Ops`, the Op/Event flow is split:
1. **Logic → Ops**: Logic computes `[]Element`. Each element carries metadata about what input ops it needs (e.g., "this TextField needs `pointer.InputOp` with `Kind: Tap` on region X").
2. **Renderer → Ops**: During the paint function, the Renderer iterates `[]Element` and submits the appropriate `pointer.InputOp` / `key.InputOp` calls into `*op.Ops`.
3. **Gioui → Events**: Gioui returns `pointer.Event` / `key.Event` via `w.Event()` in subsequent cycles.
4. **Main → Logic**: The Main goroutine batches these events into `[]InputEvent` and sends them to Logic.
5. **Logic → State**: Logic processes events, updates state, and computes a new frame.
This ensures that hit-regions are always defined in the same frame where the elements are drawn, and Logic remains testable without the SDK.
## 2. Coordinate Mapping
The editor operates in three coordinate spaces:
1. **UI Space (DP)**: The raw (X, Y) coordinates from Gioui events, relative to the window origin.
2. **Text Space (Lines/Cols)**: The logical position within the text file, accounting for scroll offset and word wrap.
3. **Byte Space (Offsets)**: The raw byte offset in the UTF-8 file buffer.
### 2.1 Hit-Region Tags
Gioui `pointer.InputOp` takes a `tag` (an `op.Tag`). When an event fires, `event.Tag` identifies which region was touched.
**Tagging Strategy**:
- The Renderer assigns a unique tag per element (e.g., `editor`, `search_bar`, `status_bar`, `alpha_index`, `merge_hunk_accept_ours`).
- When Logic receives a `pointer.Event`, it first checks the tag to determine which element was touched, then applies element-specific coordinate logic.
### 2.2 Mapping UI → Byte Offset (Editor/TextField)
When a `pointer.Event` occurs at `(Ex, Ey)` on the `editor` tag:
1. **Adjust for Scroll**: `Y = Ey + scroll_offset`.
2. **Identify Visual Line**: `visual_line = floor(Y / line_height)`.
3. **Resolve to Logical Line**: If word wrap is enabled, use the cached wrap positions to map `visual_line``logical_line`. Otherwise, they are the same.
4. **Identify Column**:
- Retrieve text for `logical_line` from the chunked buffer.
- Use the font shaper to measure glyph widths until the cumulative width exceeds `Ex`.
- Clamp to the line length.
5. **Result**: Convert `(logical_line, col_index)` to a byte offset using the **Line Index**.
### 2.3 Mapping UI → Element (Non-Text Elements)
For buttons, search bar, alpha index, merge hunks:
- Each element has a `Region()` (rectangle). The Main goroutine checks if `(Ex, Ey)` falls within the region.
- For the **Alpha Index**, the Y-coordinate maps to a letter (e.g., `letter = alphabet[floor((Y - alpha_top) / (alpha_height / 26))]`).
- For **Merge Hunks**, the tag identifies which hunk button (accept-ours / accept-theirs) was tapped.
## 3. Gestures and State Machine
The Logic goroutine maintains a small state machine for the active gesture. The state is transient (does not survive process death) and is reset on gesture completion.
### 3.1 Gesture States
| State | Entry Condition | Exit Condition | Action |
|---|---|---|---|
| `Idle` | Default state | `Press` received | — |
| `Pressed` | `Press` at (X, Y) | `Release` or `Drag` | Record press time and position |
| `Tapping` | `Release` received (short duration, no move) | Timeout or next `Press` | Move cursor, clear selection |
| `DoubleTap` | Second `Tapping` within 300ms | Gesture complete | Select word at (X, Y) |
| `LongPress` | `Press` held > 500ms | `Release` | Select word (future: magnifier) |
| `Selecting` | `Drag` received | `Release` | Update `selection_end` to current (X, Y) |
| `Scrolling` | `Drag` on non-focused area or two-finger drag | `Release` | Update `scroll_offset` |
### 3.2 Gesture Disambiguation
The Logic goroutine distinguishes gestures using timing and movement thresholds:
- **Tap vs Long Press**: If `Press``Release` occurs within 500ms, it's a tap. Otherwise, it's a long press.
- **Tap vs Drag**: If the finger moves more than 16 DP between `Press` and `Release`, it's a drag. Otherwise, it's a tap.
- **Single vs Double Tap**: If two taps occur within 300ms, the second tap triggers a word selection.
- **Select vs Scroll**: If the keyboard is visible (focused `TextField`), a single-finger drag is a selection. If the keyboard is hidden, a single-finger drag is a scroll. Two-finger drags are always scrolls.
### 3.3 Selection Logic
Selection is defined by two byte offsets: `selection_start` and `selection_end`.
- If `selection_start == selection_end`, there is no selection (just a cursor).
- During a **Drag** gesture:
- On `Press`: Set `selection_start = selection_end = offset_at(X, Y)`.
- On `Drag`: Update `selection_end = offset_at(X, Y)`.
- The UI renders a `TextField` with highlighted regions between the two offsets.
### 3.4 Double Click (Word Selection)
When a double-click is detected:
1. Identify the character at the click offset.
2. Expand left and right until a non-word character (space, punctuation, newline) is hit.
3. Set `selection_start` and `selection_end` to these boundaries.
## 4. Input Batching and Latency
As specified in `architecture.md`, the Main goroutine batches events. For touch, this is critical:
### 4.1 Event Compression in the Main Loop
The Main goroutine collects events during its event cycle. For a drag gesture, multiple `pointer.Event` (Type: `Move`) may fire before the next `FrameEvent`. The Main goroutine:
1. Accumulates all events into a `[]InputEvent` slice.
2. On the next `FrameEvent`, it sends the entire batch to Logic via `inputChan`.
3. Logic processes the batch sequentially, updating the gesture state and cursor/selection.
4. Logic produces a single frame reflecting the final state after all events.
This means that if 3 `Drag` events fire in one frame, Logic sees all 3 and produces one frame with the cursor/selection at the final position. There is no "churn" of intermediate frames.
### 4.2 Latency Budget
- **Event Capture**: Gioui captures the touch event immediately (sub-ms).
- **Batching**: Main goroutine holds events until the next `FrameEvent` (up to 16ms, but typically < 8ms).
- **Processing**: Logic processes the batch and computes layout (< 16ms).
- **Rendering**: Frame receiver stores and invalidates (sub-ms).
- **Total**: < 32ms from touch to visual feedback (typically < 16ms).
### 4.3 Gesture Continuity
Because Logic maintains the gesture state machine across frames, a multi-frame drag gesture is continuous:
- Frame 1: `Press` at A Logic enters `Pressed` state.
- Frame 2: `Drag` to B Logic enters `Selecting` state, updates `selection_end`.
- Frame 3: `Drag` to C Logic updates `selection_end` again.
- Frame 4: `Release` Logic finalizes selection, enters `Idle` state.
Each frame produces a new `[]Element` with the updated selection highlight.
## 5. Keyboard Interaction
The `TextField` element must be "focused" to receive keyboard events. Focus is a transient state managed by Logic.
### 5.1 Focus Acquisition
1. **Tap on Editor**: User taps the `TextField` region. Main goroutine sends `pointer.Event` (Type: `Press`) to Logic.
2. **Focus Request**: Logic sets `focused = true` and computes a frame with `key.FocusOp` and `key.SoftKeyboardOp(true)`.
3. **Keyboard Appears**: Gioui shows the Android soft keyboard. Subsequent `w.Event()` calls return `key.Event`.
### 5.2 Focus Loss
1. **Tap Outside Editor**: User taps a non-editor region (e.g., status bar, directory browser). Logic sets `focused = false` and computes a frame with `key.SoftKeyboardOp(false)`.
2. **Keyboard Disappears**: Gioui hides the soft keyboard.
### 5.3 Key Events
| Event | Type | Name | Action |
|---|---|---|---|
| Text insertion | `Edit` | | Insert `event.Text` at cursor, append to undo chain |
| Backspace | `Press` | `Backspace` | Delete character before cursor, append to delete chain |
| Enter | `Press` | `Enter` | Insert newline at cursor |
| Tab | `Press` | `Tab` | Insert spaces (configurable, e.g., 4 spaces) |
| Arrow Up/Down | `Press` | `Up`/`Down` | Move cursor up/down one line |
| Arrow Left/Right | `Press` | `Left`/`Right` | Move cursor left/right one character |
| Ctrl+A | `Press` | `A` (with Ctrl) | Select all text |
| Ctrl+Z | `Press` | `Z` (with Ctrl) | Undo |
| Ctrl+Y | `Press` | `Y` (with Ctrl) | Redo |
| Ctrl+F | `Press` | `F` (with Ctrl) | Show search bar |
### 5.4 IME and Composition (Pure Gioui)
Android soft keyboards use IME composition for languages like Chinese, Japanese, or Korean. Gioui's `key.Event` (Type: `Edit`) handles the final committed text. Logic treats the committed text as a single insertion at the cursor.
**Limitation**: Gioui's `key.InputOp` on Android does not provide a full `InputConnection`. The IME has no context it cannot read surrounding text for autocorrect, cannot anchor swipe gestures to our cursor, and cannot provide predictive suggestions. Only raw character commits are received.
## 5.5 Android IME Bridge via Fragment
To access the full suite of Android IME features (autocorrect, swipe typing, voice input, predictions), Pad uses a **hidden `EditText`** hosted in a native `Fragment`. This is the same pattern used by Passgo (see `cmd/passgo-gui/impl_android.go`, `PgpConnect.java`).
### 5.5.1 Fragment Registration
The Fragment is registered on app startup using `app.ViewEvent`:
1. **Go side**: `handleEvent` receives `app.ViewEvent` on app start. `e.View` is a `uintptr` pointing to the `GioView` (`android.view.View`).
2. **C side (JNI)**: `registerFragment()` receives the `jobject view`:
- Gets the `Context` from the View via `getContext()`
- Gets the `ClassLoader` from the Context via `getClassLoader()`
- Loads the Fragment class via `findClass("pad/ime/ImeFragment")`
- Creates an instance by calling the constructor with the View
3. **Java side (`ImeFragment.java`)**:
- `ImeFragment` extends `Fragment`
- Constructor receives the `View` (GioView), extracts `Context`
- In `onAttach()`, casts `Context` `Activity`
- Uses `act.getFragmentManager().beginTransaction().add(inst, "ImeFragment").commitNow()`
- Inflates a layout containing a transparent, zero-size `EditText`
### 5.5.2 IME Fragment Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Android Activity │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ GioView (OpenGL rendering) │ │
│ │ - pointer.InputOp → pointer.Event │ │
│ │ - key.InputOp → key.Event │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ ImeFragment (transparent overlay) │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ EditText (hidden, zero-size) │ │ │
│ │ │ - Holds text window around cursor │ │ │
│ │ │ - Selection synced to cursor position │ │ │
│ │ │ - IME receives all keyboard events │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 5.5.3 Two-Way Sync: Gio ↔ EditText
The IME bridge maintains a tight sync loop between the Gio editor state and the hidden `EditText`:
**Gio → EditText (Cursor Sync)**:
1. User taps to move cursor in Gio editor.
2. Logic updates `cursor_pos` in state.
3. Main goroutine calls a JNI function: `SetEditTextSelection(byteOffset)`.
4. C side calls `editText.setSelection(byteOffset)` on the `EditText`.
5. IME now knows the cursor position and can provide context-aware suggestions.
**EditText → Gio (IME Events)**:
1. User types/swipes/uses voice input on the soft keyboard.
2. IME commits text to the `EditText` via `EditText.onTextChanged()`.
3. A `TextWatcher` on the `EditText` captures the change.
4. The change is sent via JNI callback to Go: `ImeTextChanged(replacementText, start, before, count)`.
5. C side allocates memory for the string and calls a Go export function.
6. Go side creates a synthetic `key.Event` (Type: `Edit`) and sends it to the Main goroutine.
7. Main goroutine batches it into `[]InputEvent` alongside any pointer/keyboard events.
8. Logic processes the synthetic event as a text insertion at the cursor.
### 5.5.4 Text Window Management
The `EditText` does not hold the entire file only a **text window** around the cursor:
- **Window size**: ±1KB of text around the cursor (configurable).
- **On cursor move**: The Main goroutine sends a JNI call to update the `EditText`'s text and selection.
- **On text insertion/deletion**: The Logic goroutine updates the in-memory buffer, then sends a JNI call to update the `EditText`'s text and selection.
This ensures the IME always has context for suggestions, even for multi-gigabyte files.
### 5.5.5 IME Feature Support
| Feature | Supported | How |
|---|---|---|
| Autocorrect | Yes | IME reads text window from `EditText`, suggests corrections |
| Swipe typing | Yes | IME anchors swipe to `EditText` selection (cursor position) |
| Voice input | Yes | IME commits voice text to `EditText` |
| Predictive bar | Yes | IME reads text window for context |
| Emoji keyboard | Yes | IME commits emoji to `EditText` |
| CJK composition | Yes | IME composition buffer `EditText` Gio |
### 5.5.6 Event Merging in the Main Loop
IME events from the Fragment are merged with Gioui events in the Main goroutine:
```
Main goroutine event cycle:
1. w.Event() → returns next event (FrameEvent, pointer.Event, key.Event)
2. Check for pending IME events (non-blocking channel read)
3. If IME event exists → create InputEvent, add to batch
4. If Gioui event exists → create InputEvent, add to batch
5. Send entire batch to Logic via inputChan
```
IME events are **not** returned by `w.Event()` they arrive asynchronously via a JNI callback channel. The Main goroutine drains this channel in every cycle, ensuring IME events are batched alongside Gioui events and delivered to Logic atomically.
### 5.5.7 Implementation Notes
- The `ImeFragment` lives in `cmd/pad/impl_android.go` (Go), `jni_android.c` (C), and `ime/ImeFragment.java` (Java).
- The JNI callback uses a `BlockingQueue` or unbuffered channel to pass events from the Java main looper to the Main goroutine.
- The `EditText` is transparent (`android:background="@android:color/transparent"`) and zero-size (`android:layout_width="0dp" android:layout_height="0dp"`), so it does not affect the UI.
- The `EditText` is **not** focusable by touch it only receives focus programmatically when the Gio editor needs the keyboard.
## 5.6 Cut, Copy, and Paste
Pad implements its own clipboard (not Android's `ClipboardManager`) with **persistent storage** the clipboard survives process death and app restart.
### 5.6.1 Clipboard Storage
The clipboard is stored as a string field in the Logic goroutine's state and persisted to disk:
- **File**: `.pad/clipboard.json`
- **Format**: `{"text": "..."}` (UTF-8 string)
- **Write trigger**: Every cut or copy operation triggers an async write to disk (debounced, 100ms).
- **Load trigger**: On app start, Logic reads `.pad/clipboard.json` and restores the clipboard.
- **Atomic write**: Written via temp file + rename in `.pad/tmp/` (same pattern as auto-save).
This ensures the clipboard is available even if the system kills the app while it is in the background.
### 5.6.2 StatusBar Layout with Cut/Copy/Paste Icons
The StatusBar (top bar) has **fixed icon slots** for cut, copy, and paste. Icons appear/disappear without reflowing other elements.
**Layout** (vertical stack, top to bottom):
```
┌──────────────────────────────────────────────────────────────┐
│ filename.txt │ ← Line 1: filename (truncated with ellipsis)
│ [Cut] [Copy] [Paste] [Conf] [Search] │ ← Line 2: action icons (left/right)
└──────────────────────────────────────────────────────────────┘
StatusBar (top bar)
┌──────────────────────────────────────────────────────────────┐
│ TextField (editor content) │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Ln 10, Col 5 1024 / 50000 [Word Wrap: On] │ ← BottomBar
└──────────────────────────────────────────────────────────────┘
```
**Fixed slot positions** (in Dp, from the edges):
- **Cut icon**: leftmost action icon (24×24 DP, X: 0)
- **Copy icon**: second from left (24×24 DP, X: 48)
- **Paste icon**: third from left (24×24 DP, X: 96)
- **Conflict icon**: right side (24×24 DP, future)
- **Search icon**: rightmost (24×24 DP, future)
- **Filename**: top line, truncated with ellipsis if too long
**Visibility rules**:
| Icon | Visible When |
|---|---|
| **Cut** | `selection_start != selection_end` |
| **Copy** | `selection_start != selection_end` |
| **Paste** | `clipboard != ""` |
| **Conflict** | sync conflict detected for active file |
| **Search** | Always visible (toggle search bar) |
Icons are rendered as `Icon` elements with `Size=0`, so they auto-scale to fill their `24×24` DP regions via an affine transform in `r.drawPng()`. The StatusBar's `Region` height is computed dynamically based on whether the filename line is visible (2 lines when filename is shown, 1 line when in merge/search mode).
### 5.6.3 Cut Operation
1. User taps the Cut icon.
2. Logic extracts text: `clipboard = buffer[selection_start:selection_end]`.
3. Logic writes clipboard to `.pad/clipboard.json` (async).
4. Logic deletes selected text from buffer (append to delete chain).
5. Logic sets `selection_start = selection_end = cursor_pos` (clears selection).
6. Logic produces a new frame (Cut/Copy icons hidden, Paste icon may appear).
### 5.6.4 Copy Operation
1. User taps the Copy icon.
2. Logic extracts text: `clipboard = buffer[selection_start:selection_end]`.
3. Logic writes clipboard to `.pad/clipboard.json` (async).
4. Selection is **not** cleared (text remains in buffer).
5. Logic produces a new frame (Cut/Copy icons still visible, Paste icon appears).
### 5.6.4a Search Button
The Search button toggles the search bar visible/invisible.
1. User taps the Search icon.
2. Logic toggles `search_active` state field.
3. If `search_active` is true:
- Show `SearchBar` element below the StatusBar.
- Set focus to the search bar's text field.
- Show the soft keyboard (`key.SoftKeyboardOp(true)`).
4. If `search_active` is false:
- Hide `SearchBar` element.
- Clear search query.
- Remove focus from search bar.
- Hide soft keyboard (`key.SoftKeyboardOp(false)`).
5. Logic produces a new frame.
The Search button is always visible in the StatusBar (unlike Cut/Copy/Paste which are conditional). It serves as the primary way to activate search on Android (where Ctrl+F may not be available).
### 5.6.5 Paste Operation
1. User taps the Paste icon.
2. Logic inserts `clipboard` text at cursor position.
3. Logic appends an insert operation to the undo chain.
4. Logic advances cursor past the inserted text.
5. Logic produces a new frame (no change to Cut/Copy/Paste visibility).
### 5.6.6 Input Routing
Cut, Copy, and Paste icons are tagged with unique `op.Tag` values. When the user taps an icon:
1. Main goroutine receives `pointer.Event` (Type: `Press`) with the icon's tag.
2. Main goroutine sends `InputEvent{ElementID: "cut"}`, `InputEvent{ElementID: "copy"}`, or `InputEvent{ElementID: "paste"}` to Logic.
3. Logic processes the event and updates state.
### 5.6.7 Clipboard State Machine
The clipboard is a simple string field. It has no state machine it is either empty or contains text. On cut/copy, the text is replaced. On paste, the text is unchanged.
**Edge cases**:
- **Paste into empty clipboard**: No-op (Paste icon is hidden when clipboard is empty).
- **Cut with no selection**: No-op (Cut icon is hidden when there is no selection).
- **Copy with no selection**: No-op (Copy icon is hidden when there is no selection).
- **Multiple cuts/copies**: Each cut/copy replaces the previous clipboard content.
- **Clipboard persistence**: If the app is killed, the clipboard is restored from `.pad/clipboard.json` on restart.
## 5.7 Filename Display with Ellipsis Toggle
The filename is displayed on a separate line at the top of the StatusBar. When the filename is too long to fit the screen width, it is truncated with an ellipsis (`...`). Tapping the ellipsis toggles between the truncated single-line view and a full multi-line view.
### 5.7.1 Truncation Behavior
- **Truncated view**: Filename is truncated to fit the screen width, with `...` at the end. Example: `very_long_filename...`
- **Full view**: Filename is displayed in full, potentially spanning multiple lines. Example: `very_long_filename_that_does_not_fit_on_a_single_line.txt`
### 5.7.2 Toggle Interaction
1. **Tap on ellipsis**: Toggling between truncated and full views.
2. **Tap elsewhere on filename**: No action (filename text is not interactive).
3. **Ellipsis region**: The ellipsis (`...`) is a separate `Button` element with its own `op.Tag`. Tapping it triggers the toggle.
### 5.7.3 Input Routing
- The ellipsis is tagged with `op.Tag` value `"filename_toggle"`.
- When the user taps the ellipsis, the Main goroutine sends `InputEvent{ElementID: "filename_toggle"}` to Logic.
- Logic toggles the `filename_expanded` state field.
- Logic produces a new frame with the updated filename display.
### 5.7.4 State Field
The Logic goroutine maintains a boolean field `filename_expanded`:
- `false` (default): truncated view with ellipsis.
- `true`: full multi-line view.
This field is **not** persisted to disk it is reset on app restart (same as gesture state).
### 5.7.5 Layout
In the truncated view, the StatusBar has 2 lines:
```
Line 1: filename.txt...
Line 2: [Cut] [Copy] [Paste] [Conf] [Search]
```
In the full view, the StatusBar has 3+ lines (depending on filename length):
```
Line 1: very_long_filename_that_does_not_fit_on_a_single_line.txt
Line 2: [Cut] [Copy] [Paste] [Conf] [Search]
```
The `Region.H` of the StatusBar is computed dynamically based on the number of lines. The BottomBar is always at the bottom of the screen (24 DP fixed height).
## 5.8 Bottom Bar
The Bottom Bar appears at the bottom of the editor page. It displays cursor position, byte position, and word wrap status. It is always visible.
### 5.8.1 Layout
```
┌──────────────────────────────────────────────────────────────┐
│ Ln 10, Col 5 1024 / 50000 [Word Wrap: On] │
└──────────────────────────────────────────────────────────────┘
```
- **Left**: Cursor position (e.g., "Ln 10, Col 5")
- **Center**: Byte position (e.g., "1024 / 50000")
- **Right**: Word wrap button (tap to toggle On/Off)
The BottomBar's `Region.H` is fixed at 24 DP. It sits below the editor content.
### 5.8.2 Cursor Position
The cursor position is computed from the current byte offset:
1. Logic converts byte offset to (line, column) using the **Line Index**.
2. The `CursorPos` field is set to `"Ln {line}, Col {column}"`.
3. The field is updated on every cursor movement (tap, arrow keys, backspace, etc.).
### 5.8.3 Byte Position
The byte position shows the current byte offset and total file size:
- **Format**: `"current / total"` (e.g., "1024 / 50000")
- **Current**: byte offset of the cursor in the file
- **Total**: total file size in bytes
- The field is updated on every cursor movement.
### 5.8.4 Word Wrap Button
The word wrap button toggles word wrap on/off:
- `true` "Word Wrap: On"
- `false` "Word Wrap: Off"
**Interaction**:
1. User taps the word wrap button in the BottomBar.
2. Main goroutine receives `pointer.Event` (Type: `Press`) with the button's tag.
3. Main goroutine sends `InputEvent{ElementID: "word_wrap"}` to Logic.
4. Logic toggles the `word_wrap_enabled` state field.
5. Logic produces a new frame (BottomBar shows updated status).
The word wrap status is persisted to `.pad/state.json` and restored on app start.
## 6. Scroll Momentum
Pad implements a momentum model in the Logic goroutine to provide smooth scrolling.
### 6.1 Scroll Sources
| Source | Event | Behavior |
|---|---|---|
| **Mouse Wheel** | `pointer.Event` (Source: `MouseWheel`) | Direct scroll by `event.Scroll.Y` DP |
| **Finger Drag (No Focus)** | `pointer.Event` (Type: `Move`, no keyboard) | Direct 1:1 mapping of finger movement to `scroll_offset` |
| **Fling** | `pointer.Event` (Type: `Release`, high velocity) | Momentum-based scroll with exponential decay |
### 6.2 Fling Logic
When a `Release` event occurs with a high vertical velocity:
1. **Velocity Calculation**: Logic tracks the position and timestamp of the last few `Move` events. On `Release`, it calculates the velocity (DP/ms).
2. **Momentum Start**: If velocity > threshold (e.g., 0.5 DP/ms), Logic enters a `Flinging` state.
3. **Decay**: On each subsequent frame (triggered by a timer or periodic `sendFrame()`), Logic updates `scroll_offset` by `velocity * elapsed_ms` and multiplies `velocity` by a decay factor (e.g., 0.95).
4. **Stop**: When velocity drops below a minimum (e.g., 0.01 DP/ms), Logic stops the fling and returns to `Idle`.
### 6.3 Scroll Clamping
`scroll_offset` is clamped to ensure the viewport never scrolls past the top or bottom of the file:
- **Min**: 0 (top of file)
- **Max**: `total_file_height - viewport_height` (bottom of file)