Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ listed under a **Changed** or **Removed** heading.
cell and style assertions. Every Rust block in it is compiled against the
crate in CI, and the README shows the one-line install for Claude Code.

### Changed

- **`Terminal::drag` takes four column-first coordinate arguments instead of
two unlabelled tuples.** This matches the other mouse methods and prevents a
row-first `Screen::find` result from being passed through transposed. (#257)

## [0.9.0] - 2026-09-05

### Added
Expand Down
2 changes: 2 additions & 0 deletions crates/termlens/src/screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,8 @@ impl Screen {

/// Locate the first occurrence of `needle` scanning rows top to bottom;
/// returns the `(row, col)` of its first character.
/// Mouse methods such as [`Terminal::drag`](crate::Terminal::drag) take
/// columns first, so destructure this pair before using it as mouse input.
///
/// The **visible screen only**, like [`contains`](Self::contains): a
/// needle that has scrolled into history is not found here, however
Expand Down
16 changes: 12 additions & 4 deletions crates/termlens/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2510,8 +2510,12 @@ impl Terminal {
self.write_input(&bytes, "a mouse click")
}

/// Drag from one cell to another: press, a motion report **per cell
/// crossed**, release.
/// Drag from `(from_col, from_row)` to `(to_col, to_row)`: press, a
/// motion report **per cell crossed**, release.
///
/// Coordinates are columns first, matching [`click`](Self::click) and
/// the other mouse methods. [`Screen::find`](crate::Screen::find) returns
/// `(row, col)` instead, so destructure its result before passing it here.
///
/// The path is a straight line, interpolated on both axes for a diagonal
/// drag. A real pointer's exact path is not reproducible and does not
Expand Down Expand Up @@ -2543,8 +2547,10 @@ impl Terminal {
pub fn drag(
&mut self,
button: impl Into<MouseChord>,
from: (u16, u16),
to: (u16, u16),
from_col: u16,
from_row: u16,
to_col: u16,
to_row: u16,
) -> Result<()> {
self.ensure_deliverable("a mouse drag")?;
let chord = button.into();
Expand All @@ -2562,6 +2568,8 @@ impl Terminal {
MouseMode::ButtonMotion | MouseMode::AnyMotion => true,
};
let (cols, rows) = self.screen().size();
let from = (from_col, from_row);
let to = (to_col, to_row);
// Endpoints only — see the Errors section above.
check_mouse_in_grid(from.0, from.1, cols, rows)?;
check_mouse_in_grid(to.0, to.1, cols, rows)?;
Expand Down
13 changes: 6 additions & 7 deletions crates/termlens/tests/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ fn buttons_modifiers_drag_and_horizontal_wheel_reach_the_wire() -> termlens::Res
t.scroll_with(termlens::Scroll::Up.ctrl(), 5, 5)?;
t.scroll_with(termlens::Scroll::Down.shift(), 5, 5)?;
// Drag left button from (2,2) to (6,3): press, motion (0+32), release.
t.drag(termlens::MouseButton::Left, (2, 2), (6, 3))?;
t.drag(termlens::MouseButton::Left, 2, 2, 6, 3)?;

t.wait_until(|s| s.row_text(0).contains("|"))?;
let text = t.screen().row_text(0);
Expand Down Expand Up @@ -279,7 +279,7 @@ fn a_drag_reports_one_motion_per_cell_crossed() -> termlens::Result<()> {
t.wait_until(|s| s.contains("READY"))?;

// Seven cells crossed, from column 5 to column 12 on row 4.
t.drag(termlens::MouseButton::Left, (5, 4), (12, 4))?;
t.drag(termlens::MouseButton::Left, 5, 4, 12, 4)?;
t.wait_until(|s| s.row_text(0).contains("|"))?;
let text = t.screen().row_text(0);

Expand Down Expand Up @@ -317,7 +317,7 @@ fn press_release_tracking_still_gets_no_motion_at_all() -> termlens::Result<()>
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("READY"))?;
t.drag(termlens::MouseButton::Left, (5, 4), (12, 4))?;
t.drag(termlens::MouseButton::Left, 5, 4, 12, 4)?;
t.wait_until(|s| s.row_text(0).contains("|"))?;
let text = t.screen().row_text(0);
assert!(!text.contains("[<32;"), "no motion under ?1000:\n{text}");
Expand All @@ -339,7 +339,7 @@ fn drag_is_refused_when_the_mode_cannot_express_it() -> termlens::Result<()> {
t.wait_until(|s| s.contains("READY"))?;

let err = t
.drag(termlens::MouseButton::Left, (1, 1), (4, 4))
.drag(termlens::MouseButton::Left, 1, 1, 4, 4)
.expect_err("X10 reports presses only");
assert!(matches!(err, Error::Input(_)), "got: {err}");
assert!(err.to_string().contains("X10"), "unhelpful: {err}");
Expand Down Expand Up @@ -428,7 +428,7 @@ fn mouse_events_outside_the_grid_are_refused() -> termlens::Result<()> {
assert!(err.to_string().contains("20x5"), "{err}");

let err = t
.drag(termlens::MouseButton::Left, (0, 0), (20, 0))
.drag(termlens::MouseButton::Left, 0, 0, 20, 0)
.expect_err("off-grid drag endpoint");
assert!(matches!(err, Error::Input(_)), "got: {err}");
assert!(err.to_string().contains("(20, 0)"), "{err}");
Expand Down Expand Up @@ -550,8 +550,7 @@ fn a_mouse_click_at_a_departed_child_blames_the_child() -> termlens::Result<()>
for err in [
t.click(1, 1).unwrap_err(),
t.scroll(1, 1, Scroll::Up).unwrap_err(),
t.drag(termlens::MouseButton::Left, (1, 1), (2, 2))
.unwrap_err(),
t.drag(termlens::MouseButton::Left, 1, 1, 2, 2).unwrap_err(),
] {
assert!(matches!(err, Error::Write { .. }), "got: {err}");
assert!(
Expand Down
12 changes: 6 additions & 6 deletions skills/termlens/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ your test ── send(Key) · click · paste · resize ──▶ PTY └─
col)`, `row_text(row)`, `cursor()` → `(row, col, visible)`. Everything
that speaks of terminal geometry or a pointer is **column-first**:
`size()` → `(cols, rows)`, `resize(cols, rows)`, `click(col, row)`,
`scroll(col, row, …)`, `drag(button, (col, row), (col, row))`. Never
pass a `find` result straight into `drag` — the tuple types match and
the axes do not.
`scroll(col, row, …)`, `drag(button, from_col, from_row, to_col,
to_row)`. The four drag arguments make a transposed `find` result
unwritable without explicitly choosing the coordinate order.

7. **Always finish the process.** Send the quit key, `wait_exit()?` and
assert on the `ExitStatus` (`success()`, `code()`, `signal()`), then
Expand Down Expand Up @@ -366,8 +366,8 @@ Read the first line for the cause:
**Drive**: `send(Key)`, `send_str("text")` (no Enter — send `Key::Enter`
yourself; `"\n"` would send LF, not CR), `paste("text")` (bracketed if the
app enabled it), `send_after(delay, Key)`, `click(col, row)`,
`click_with(MouseButton::Right, col, row)`, `drag(MouseButton::Left, (c, r),
(c, r))`, `scroll(col, row, Scroll::Down)`, `resize(cols, rows)`,
`click_with(MouseButton::Right, col, row)`, `drag(MouseButton::Left, from_c,
from_r, to_c, to_r)`, `scroll(col, row, Scroll::Down)`, `resize(cols, rows)`,
`focus_in()` / `focus_out()`, `signal(Signal::Term)` (Unix), `pid()`.

**Keys**: `Key::Char('j')`, `Enter`, `Esc`, `Tab`, `BackTab`, `Backspace`,
Expand Down Expand Up @@ -411,7 +411,7 @@ the embedded screen when there is one.
| `t.wait_until(a)?; assert!(t.screen().b);` | `t.wait_until(\|s\| a(s) && b(s))?;` |
| `.size(0, 0)` / `.size(1, 1)` | leave the 80x24 default, or `.size(cols, rows)` with both in 2..=1000 |
| `Terminal::builder().spawn("myapp")` | `termlens::bin!("myapp")?` — a name on `PATH` is not your binary, and under `env_clear` there is no `PATH` |
| `t.click(row, col)` / `t.drag(b, s.find("x").unwrap(), …)` | `t.click(col, row)`; destructure the `find` result and swap |
| `t.click(row, col)` / passing `s.find("x")` coordinates to `t.drag(b, …)` unchanged | `t.click(col, row)`; destructure the `find` result and pass each coordinate in column-first order |
| `t.send_str("quit\n")` | `t.send_str("quit")?; t.send(Key::Enter)?;` |
| `t.wait_frame(…)` against a ratatui app | `t.snapshot_after(…)` unless the app emits synchronized updates |
| `t.click(3, 4)?` as the first thing after spawn | `t.wait_until(\|s\| s.mouse_mode() != MouseMode::None)?;` first |
Expand Down