Rust Terminal Novel Reader Gets Bell-Ringing Mouse Support: Hit Testing, Pointer Capture, and the Art of Not Swallowing Events
Who says terminals are keyboard-only? I added mouse support to a novel reader with Rust
In a terminal, reading novels, using the scroll wheel to flip pages, clicking the table of contents to jump chapters, and dragging the scrollbar to adjust reading position.
All of these operations can now be done in my Rust novel reader TRNovel.
Figure 1: TRNovel home page in PowerShell, with entries for local novels, web novels, history, and global settings.
However, the four words "mouse support" hide more problems than the UI suggests.
Buttons in pop-ups are clearly drawn, so why can't I click them?
The scroll wheel just moved one extra notch, so why did it jump to the next chapter?
When dragging the scrollbar, if the mouse leaves the track, should dragging end?
All these questions point to one thing: receiving mouse coordinates is just the start. We also need to know what was hit, who handles it first, and where subsequent events go.
I built the general capabilities into ratatui-kit, which I maintain, then had TRNovel use it. Let's first look at the reader in action:
Figure 2: From the same PowerShell screen recording, the mouse was physically operated by me; the clip shows web novel opening, table-of-contents operations, and text position changes. The terminal title bar and real mouse pointer are preserved, and no simulated UI is used.
Now let's walk through a single click. No need to know the whole framework first—we start from "click a book."
1. Click the mouse, and the program receives a string of characters
Suppose you're about to open a book on the shelf and click the mouse.
The program doesn't receive "open this book," not even "click this list." What it actually receives as raw input can be a string like this:
\x1b[<0;6;5M
Don't be scared off. The information here is simple: which button, which column, which row, and what action occurred.
In this example, it indicates a left-button press at protocol coordinates column 6, row 5. After crossterm parses it, you get a mouse event usable by the app, with coordinates converted to zero-based (5, 4).
graph TD
A["Mouse: press here"] --> B["Terminal: report button and coordinates"]
B --> C["crossterm: parse event"]
C --> D["Framework: dispatch to hit component"]
D --> E["Reader: open this book"]
Figure 3: The terminal reports, crossterm parses, the framework finds the component, and business code executes the action.
There's a prerequisite: the app must first tell the terminal it wants mouse events.
Otherwise, behaviors like selecting text or scrolling back through output are usually handled by the terminal itself, and the app may not receive the corresponding input.
In ratatui-kit, the entry point explicitly enables it:
// Application entry snippet
// Use ratatui_kit::prelude::*, App is the application root component
element!(App)
.fullscreen_with(RunOptions { mouse: true })
.await?;
The underlying layer handles enabling reporting and, on exit, disables capture and restores terminal behavior. Components only need to handle parsed events.
At this point, the program knows "someone pressed the left button at (5, 4)." But there might be a dozen books on the shelf—how does it know which one you clicked?
2. Give components a "position note"
Think of the terminal as a sheet of grid paper.
Each component occupies a region: the list on the left, the text on the right, and a button might occupy only a small piece of it. Given a grid coordinate from the mouse, the framework checks: which components' visible regions does this point fall into?
This process is called hit testing. The name sounds technical, but it's really just "calling roll."
But component positions aren't fixed. When the terminal shrinks, the layout changes; when text scrolls, content positions change. So you can't compute coordinates once at component creation and keep using them.
My approach: each time you draw, update a "position note" for the component.
| What the note records | What problem it solves |
|---|---|
| The actually visible screen area | Does the click land here? |
| Coordinate transformation | After content scrolls, where on screen is it? |
| Draw frame number | Is this position record still valid? |
| Draw order | When overlapping, who is drawn on top? |
In the source, this record is called Stamp. Just remember its purpose for now; you don't need to read every field at once.
Why emphasize "visible area"?
Imagine a table of contents with 100 chapters, but the window only shows 20.
Chapter 80 is still in the content, but it's not on screen. Naturally, it shouldn't suddenly respond to a click just because its internal coordinates happen to match.
So the framework needs to convert content coordinates to screen coordinates and then take the intersection with the scroll container's visible range. Nested containers also need to be transformed and clipped layer by layer.
graph TD
A["Component's full region in content"] --> B["Convert to screen position"]
B --> C["Intersect with scroll viewport"]
C --> D{"Visible this frame?"}
D -->|"Visible part"| E["Participate in mouse hit"]
D -->|"Scrolled out / clipped"| F["Not in hit testing"]
Figure 4: Having content doesn't mean it's clickable. Only the portion actually displayed in this frame participates in hit testing.
The frame number is like a date on the note: if the component wasn't drawn this frame, it can't keep receiving clicks based on old position. It solves the problem of "the button has disappeared, but that empty space can still be clicked."
Where you draw and where you can click must use the same geometric information.
However, even if all regions are computed correctly, clicks can still fail. Because the same position often hits more than one component.
3. When you click a button, don't let its parent answer first
A button sits inside a card, and the card sits inside a page.
When you click the button, that coordinate is simultaneously inside the button, the card, and the page. All three handlers may meet the hit condition.
Who processes first?
If you follow registration order, parents usually register before children. Once a parent says "I've handled it," the button never gets a chance.
I used the framework's existing draw order: parents register drawing info first, then children draw later. At the same input layer and same priority, let the component drawn later process mouse events first.
Each handler only needs to answer two results:
EventResult::Consumed // I've taken it, stop further dispatch
EventResult::Ignored // I don't handle it, keep looking
graph TD
A["Click button in card"] --> B["Button processes first"]
B --> C{"Button's answer"}
C -->|"Consumed: handled"| D["Stop, don't trigger card again"]
C -->|"Ignored: not handled"| E["Continue to card"]
E --> F["Still ignored, continue to page handler"]
Figure 5: This diagram shows a simple parent-child nesting case, assuming same layer and same priority.
So clicking the button hands it to the button first; after the button handles and consumes it, later components won't execute again.
If you're familiar with frontend, this resembles event bubbling. But it's not a full DOM model: the framework continues dispatching to sorted hit handlers, and overlapping sibling components can also participate.
That "drawn but unclickable" button, what's wrong?
The problem lies in priority.
The framework's in-layer mouse order is: first input layer, then handler priority, then draw order, and finally registration order as a fallback.
Previously, a pop-up parent's keyboard handler used High priority. After handling its keyboard branch, it returned Consumed for everything else.
So when the mouse arrived: the parent handler executed first, swallowed the event, and the child button never got its callback.
The fix was small: keyboard handlers only consume keys they recognize, and return Ignored for other events.
This is also a practical rule when writing component events: only handle what you're responsible for. Swallowing everything as a fallback might look convenient, but it causes problems when components are composed.
4. Why does a scrollbar still own the drag when the mouse leaves the track?
Now try dragging the scrollbar.
Press, pull down, and your hand drifts slightly, so the pointer leaves the narrow track.
If the framework still only looks at "which rectangle the mouse is currently in," the scrollbar won't receive subsequent events. Worse, the final release might be lost, and the component might think you're still holding.
There are two distinct questions here:
- Who did you hit at the start? That's hit testing.
- Who owns this drag to the end? That's pointer capture.
Think of it as: when you press, the owner is determined; subsequent moves and releases keep going to that owner until the operation ends.
sequenceDiagram
participant P as Mouse
participant R as Framework
participant S as Scrollbar
P->>R: Down: press inside track
R->>S: Hit, dispatch to scrollbar
S-->>R: Consumed: take over this press
Note over R,S: Establish capture, remember owner
P->>R: Drag: already outside track
R->>S: Still send directly to scrollbar
P->>R: Up: release outside area
R->>S: Deliver release event
Note over R,S: Release capture, operation ends
*Figure 6: Pointer capture changes the event dispatch target, not the physical mouse constraints.
In ratatui-kit, when a hit-type pointer handler consumes a button's Down, it establishes implicit capture. Subsequent Drag and Up for the same button are sent directly to it, and the capture is released after Up.
Here the owner needs a stable identity. The handler table is rebuilt every frame; using an array index as pointer could point to the wrong handler in the next frame. So the framework uses a cross-frame stable PointerToken.
Capture also becomes invalid when the component is unmounted, not drawn this frame, or its input layer is no longer active, avoiding sending events to a departed component.
By the way: does pressing count as a click?
Not necessarily.
use_click waits for a Down/Up pair and only generates a Click if the release is still within the component's hit area. If you press, drag out of the area, and release, no click is generated.
That's the semantics of use_click; the built-in Button's press callback fires on left-button Down. When choosing an API, consider whether you need "respond immediately on press" or "confirm on release."
5. Back to the reader: scrolling down, wheel and keyboard can't be copied
The framework already knows where to dispatch events. Now TRNovel needs to turn that into actions that fit reading habits.
The most interesting part isn't a complex algorithm—it's a few small interaction decisions.
Clicking a list: find the item first, then reuse Enter's action
A shelf item can have multiple lines: one for the title, others for additional info. After scrolling, the top item might only be half-visible.
So you can't simply treat "which row the mouse is on" as "which item is selected."
TRNovel uses the underlying list state's hit_test to find the item from actual render records, then goes through the same business callback as Enter.
Mouse handles positioning; business actions are reused. This avoids maintaining two separate "open this book" paths.
Scrolling the text: stop at chapter end, don't decide to turn pages for the reader
The keyboard has a clear single press. The scroll wheel is different; one notch often produces a continuous stream of events.
If you directly connect the wheel to "confirm page turn after chapter end," the same scroll could trigger the confirmation prompt and then immediately confirm it again.
The user just wanted to read a bit further, but the chapter jumped away.
So the wheel in the text area only scrolls within the chapter:
| User action | Reader behavior |
|---|---|
| Scroll up | Move up, at most to chapter start |
| Scroll down | Move down, at most to chapter bottom |
| At chapter end, continue scrolling | Stay on current chapter |
| Manual scroll while listening | Pause text auto-follow, allow looking back |
The boundary calculation is only a few lines. Below is the core snippet, where end_scroll represents the bottom position of the chapter text:
let next = if matches!(ev.kind, PointerKind::ScrollUp) {
line.saturating_sub(WHEEL_LINES)
} else {
(line + WHEEL_LINES).min(end_scroll)
};
Scrolling up never goes below 0; scrolling down never exceeds the chapter's upper bound. Explicit page turns are still left to the keys.
Opening a popup: don't let background text scroll behind it
When the user opens settings, the operation target becomes the settings panel.
The framework uses an exclusive input layer to temporarily remove background normal handlers from active dispatch. The panel then processes clicks and wheel events with its own priority.
The layer stack answers "which layer is being operated on now," and the in-layer sorting answers "who in this layer processes first." Separating these two questions makes mouse logic easy to compose.
6. What does a component developer actually need to write?
Earlier we covered a lot of underlying mechanics, but ordinary components don't need to reimplement them every time.
ratatui-kit packages common interactions into several hooks:
| What you want to do | What to use |
|---|---|
| Confirm after press and release inside area | use_click |
| Highlight on mouse enter, restore on leave | use_hover |
| Drag separators, track dragging process | use_drag |
| Handle wheel, or decide whether to consume an event | use_pointer |
Hover has an easily overlooked detail: mouse movement doesn't always require redrawing the UI.
If it's just a button highlight, you only care about "enter" and "leave." Moving from left to right within the same button doesn't change the UI, so you don't need to refresh on every movement report.
The framework centrally compares changes in the hover set; components that need per-position feedback can additionally track Move. Move observation is dispatched independently, also avoiding a component consuming an event and causing another to miss the leave notification and stay highlighted forever.
This is exactly why I put these capabilities into the framework: the reader handles reading experience, and common components handle generic interactions. Next time I build a table, menu, or picker, I can reuse them.
7. After adding mouse support, next components are easier
Suppose the next task is a "switch theme" popup for the reader.
Previously, you'd need to judge mouse coordinates in the page, compute which row was hit, and remember to block the background text's wheel. For a different popup, you might write that logic again.
Now you can directly compose existing capabilities: input layers isolate the background, lists map click positions to items, and business code only handles "user selected this theme." If you switch to a drag-based control, pointer capture also handles the follow-up moves and releases.
This is the value of building mouse support into the framework: new components don't have to get to know the mouse from scratch.
Of course, the framework can't decide all interactions for the app. Whether to turn pages at chapter end on wheel, or whether a click selects or opens immediately—these are still TRNovel's choices based on the reading scenario.
After this implementation, I'm more inclined to build along this boundary: the framework handles how events reach components; the reader handles how to make received events feel good.
Finally, welcome to click around in your terminal
From a mouse coordinate to opening a book, we've handled hit testing, dispatch, capture, and reading habits. Putting these details together is what gives terminal buttons, tables of contents, and scrollbars that "click and it should feel right" touch.
If the demo above makes you want to try it, both projects are open source:
- Want to read novels in the terminal: try TRNovel, starting with opening a local TXT or importing your own web novel source.
- Want to build your own terminal app: check out ratatui-kit. You can start with the Chinese documentation and the earlier mouse examples; components, hooks, and input layers are all reusable.
If you find the project interesting, feel free to star it on GitHub and keep it for later. If you encounter unclickable areas, scrolling issues, or inconsistent behavior in a particular terminal, please file an issue with the terminal name and reproduction steps—that makes it easier to find the problem.
When you use terminal apps, do you prefer full keyboard, or do you also want to click around when needed? Feel free to share in the comments, and also tell us which mouse interaction you'd most like to see in a terminal.
Thanks for reading, and thanks to everyone willing to try, report issues, and help improve the project together.