diff options
Diffstat (limited to 'src/mouse.zig')
| -rw-r--r-- | src/mouse.zig | 112 |
1 files changed, 104 insertions, 8 deletions
diff --git a/src/mouse.zig b/src/mouse.zig index ac72481a..36786612 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -48,6 +48,14 @@ pub const Drag = union(enum) { button: Mouse.Button, chorded: bool = false, ctrl: bool = false, + /// A B1 body sweep held past the pane's top (-1) or bottom (1): the + /// body scrolls a line a tick, as acme's frselect scrolls + /// (libframe frselect.c, the `scroll` callback while the button is + /// held outside the frame). + edge: i2 = 0, + /// Where the sweep began in the text, once it has scrolled: its + /// press row may be off the screen. + anchor: ?modal.Cursor = null, /// This gesture began over a usable native PDF raster. The payload is /// zero-bit when PDF support is absent. pdf: panes.Pdf.PointerDrag = .{}, @@ -625,6 +633,17 @@ pub fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) vo // from the pointer. const body_y = p.bodyTop(pane, r); const body_h = r.h -| pane.tag_rows; + s.edge = if (s.button != config.select_button or pane.sel[b].r0 < tag_rows or pane.file == null or pane.hasPdf() or body_h == 0) + 0 + else if (mrow < body_y) + -1 + else if (mrow >= body_y + body_h or + // The pointer cannot leave the screen: at a bottom pane, + // its last row is the edge. + (body_y + body_h >= p.screen_h and mrow + 1 >= body_y + body_h)) + 1 + else + 0; pane.sel[b].r1 = if (pane.sel[b].r0 < tag_rows) tag_line else if (body_h == 0) @@ -670,11 +689,12 @@ fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: const pane = p.panes[id] orelse return .{}; if (on_tag) { const tag = tagline.tagText(p, p.scratch.allocator(), pane) catch return .{}; + const shown = tagline.shownTag(p, p.scratch.allocator(), pane) catch return .{}; const sel = operand.expanded orelse operand.preview orelse return .{}; const lead = if (sel.r0 < sel.r1 or (sel.r0 == sel.r1 and sel.c0 <= sel.c1)) sel else Pane.Sel{ .r0 = sel.r1, .c0 = sel.c1 }; const row: usize = @as(usize, @intCast(@max(0, lead.r0))) + pane.tag_top; - const start = modal.lineStartOffset(tag, row); - const at = start + panes.File.rawAtDisplay(modal.lineSlice(tag, row), @intCast(@max(0, lead.c0))); + const start = modal.lineStartOffset(shown.text, row); + const at = shown.toReal(start + panes.File.rawAtDisplay(modal.lineSlice(shown.text, row), @intCast(@max(0, lead.c0)))); const q0: u32 = @intCast(@min(at, tag.len)); return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, tag.len)) }; } @@ -695,12 +715,13 @@ fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: const start: ?modal.Cursor = if (operand.file_word) |w| .{ .row = @intCast(@max(0, w.row)), .col = @intCast(@max(0, w.lo)) } else if (operand.expanded orelse operand.preview) |sel| lead: { - // A selection's leading corner in reading order, converted the way + // A selection's start in reading order, converted the way // `pointerOperand` converts the click itself. - const top = @min(sel.r0, sel.r1) - @as(i32, pane.tag_rows); + const lead = edit.sweepEnds(sel)[0]; + const top = lead[0] - @as(i32, pane.tag_rows); if (top < 0) break :lead null; const w = pane.wrapAt(top); - const col = edit.paneByteAtDisplay(p, &pane.body, w.line, w.at, @min(sel.c0, sel.c1) - panes.File.gutterWidth(pane)); + const col = edit.paneByteAtDisplay(p, &pane.body, w.line, w.at, lead[1] - panes.File.gutterWidth(pane)); break :lead .{ .row = @intCast(@max(0, w.line)), .col = @intCast(@max(0, col)), @@ -734,10 +755,11 @@ fn mirrorTtySelection(p: *Pardes, pane: *Pane) void { const rows = pane.pointerSelection(gesture) orelse return; const text = pane.rawPointerText(gesture) orelse return; if (rows.len == 0) return; - const endpoint = rows[if (gesture.r1 >= gesture.r0) rows.len - 1 else 0]; + const forward = gesture.r1 > gesture.r0 or (gesture.r1 == gesture.r0 and gesture.c1 >= gesture.c0); + const endpoint = rows[if (forward) rows.len - 1 else 0]; const body = body_layer.bodyText(p, p.scratch.allocator(), pane) catch return; const line = modal.lineSlice(body, @intCast(@max(0, endpoint.row - panes.Terminal.gridOffset(pane)))); - const byte = if (gesture.c1 >= gesture.c0) modal.prevGrapheme(line, @min(endpoint.hi, line.len)) else endpoint.lo; + const byte = if (forward) modal.prevGrapheme(line, @min(endpoint.hi, line.len)) else endpoint.lo; pane.body.cur_row = pane.surfRow(endpoint.row); var shown = std.mem.trimEnd(u8, line[@min(endpoint.prompt_bytes, line.len)..], " \t"); if (pane.ovl) |overlay| { @@ -757,11 +779,70 @@ fn mirrorTtySelection(p: *Pardes, pane: *Pane) void { edit.setYank(p, text); } +/// acme's double-click (text.c:881 textselect, plan9port text.c:1407 +/// textdoubleclick): a second plain B1 click at the same cell within half a +/// second selects what edit.doubleClickRange says, from the caret the click +/// left. A plain click still puts the caret on its cell. +fn doubleClick(p: *Pardes, id: usize, t: *panes.Text, on_tag: bool, row: i32, col: i32) void { + const now = clockMs(); + const prev = p.last_click; + p.last_click = .{ .id = id, .on_tag = on_tag, .row = row, .col = col, .ms = now }; + const c = prev orelse return; + if (c.id != id or c.on_tag != on_tag or c.row != row or c.col != col or now - c.ms > 500) return; + p.last_click = null; + const text = edit.flatSurface(p, t) catch return; + const q = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }); + const r = edit.doubleClickRange(text, q); + if (r[1] <= r[0]) return; + const a = modal.positionAt(text, r[0]); + const h = modal.positionAt(text, modal.prevGrapheme(text, r[1])); + t.vsel = .{ .active = true, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; + t.cur_row = @intCast(h.row); + t.cur_col = @intCast(h.col); + t.cur_pinned = true; +} + +/// On each tick while a B1 body sweep is held past an edge: the body +/// scrolls a line toward it, the sweep's end stays under the pointer and +/// its start stays on its text (Drag.select.anchor). +pub fn edgeScroll(p: *Pardes) void { + if (p.drag != .select) return; + const s = &p.drag.select; + if (s.edge == 0) return; + const pane = p.panes[s.id] orelse return; + const sl = &pane.sel[sel_slot]; + if (s.anchor == null) s.anchor = sweepPoint(p, pane, sl.r0, sl.c0) orelse return; + const before = pane.scroll(); + pane.scrollBy(s.edge); + // The start's row follows its text while it is on the screen, and + // stays on the body's edge row once it is off (the anchor keeps it). + const top: i32 = pane.tag_rows; + sl.r0 = std.math.clamp(sl.r0 - (pane.scroll() - before), top, top + @as(i32, pane.rows) - 1); +} + +/// The text position under a body cell of the sweep's space. +fn sweepPoint(p: *Pardes, pane: *panes.Pane, r: i32, c: i32) ?modal.Cursor { + const visible = r - @as(i32, pane.tag_rows); + if (visible < 0) return null; + const w = pane.wrapAt(visible); + if (w.line < 0) return null; + const col = edit.paneByteAtDisplay(p, &pane.body, w.line, w.at, c - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0)); + return .{ .row = @intCast(w.line), .col = @intCast(@max(0, col)) }; +} + +fn clockMs() i64 { + if (comptime !pardes.hosted) return 0; + var ts: std.c.timespec = undefined; + if (std.c.clock_gettime(.MONOTONIC, &ts) != 0) return 0; + return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000); +} + fn dragRelease(p: *Pardes, button: Mouse.Button) void { // A different button's release belongs to a chord or another host // stream; only the button which began this gesture may end it. if (p.drag.button() != button) return; defer p.drag = .none; + defer p.chord_hold = false; switch (p.drag) { .border_v => |d| { const c = d.left_col; @@ -844,10 +925,11 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { } else if (tagline.tagOffsetAt(p, pane, row, col)) |cursor| { pane.tag.cur_row = @intCast(cursor.row); pane.tag.cur_col = @intCast(cursor.col); + if (!dragged) doubleClick(p, s.id, &pane.tag, true, sl.r1, sl.c1); } return; } - if (dragged) edit.capturePointerSelection(p, pane, sel_slot) catch {}; + if (dragged and s.anchor == null) edit.capturePointerSelection(p, pane, sel_slot) catch {}; if (dragged and pane.body.mode == .tty) mirrorTtySelection(p, pane); const body_vis = sl.r1 - @as(i32, pane.tag_rows); if (body_vis >= 0 and pane.body.mode != .tty) { @@ -869,6 +951,15 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { pane.body.select = false; pane.body.restore_cursor = false; pane.body.sticky_col = -1; + // A ctrl-click is a definition jump, not half a double-click. + if (!dragged and !s.ctrl) doubleClick(p, s.id, &pane.body, false, sl.r1, sl.c1); + if (s.ctrl) p.last_click = null; + // A sweep that scrolled has its start off the screen: it + // is kept as the text's own selection, start to end. + if (dragged) if (s.anchor) |a| { + pane.sel[sel_slot].state = .none; + pane.body.vsel = .{ .active = true, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; + }; if (s.ctrl) p.lspRequest(s.id, .definition, ""); } } else { @@ -1001,6 +1092,11 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { } if (!s.chorded) { s.chorded = true; + // Everything done while B1 stays down is one undo step, as acme + // marks the file once per B1 hold (text.c:881 textselect, `state` + // and its one filemark): B1-B2 then B1-B3 is a copy. + edit.pushUndo(p, &pane.body); + p.chord_hold = true; const sl = pane.sel[sel_slot]; pane.sel[sel_slot].state = .none; const pfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; |
