diff --git a/xray_core/src/buffer_view.rs b/xray_core/src/buffer_view.rs index 5c365d82..5aedd838 100644 --- a/xray_core/src/buffer_view.rs +++ b/xray_core/src/buffer_view.rs @@ -70,6 +70,10 @@ enum BufferViewAction { SelectDown, SelectLeft, SelectRight, + SelectTo { + row: u32, + column: u32, + }, SelectToBeginningOfWord, SelectToEndOfWord, SelectToBeginningOfLine, @@ -510,6 +514,20 @@ impl BufferView { self.autoscroll_to_cursor(false); } + pub fn select_to(&mut self, position: Point) { + self.buffer + .borrow_mut() + .mutate_selections(self.selection_set_id, |buffer, selections| { + for selection in selections.iter_mut() { + let anchor = buffer.anchor_before_point(position).unwrap(); + selection.set_head(buffer, anchor); + selection.goal_column = None; + } + }) + .unwrap(); + self.autoscroll_to_cursor(false); + } + pub fn move_up(&mut self) { self.buffer .borrow_mut() @@ -1087,6 +1105,10 @@ impl View for BufferView { Ok(BufferViewAction::SelectDown) => self.select_down(), Ok(BufferViewAction::SelectLeft) => self.select_left(), Ok(BufferViewAction::SelectRight) => self.select_right(), + Ok(BufferViewAction::SelectTo { + row, + column + }) => self.select_to(Point::new(row, column)), Ok(BufferViewAction::SelectToBeginningOfWord) => self.select_to_beginning_of_word(), Ok(BufferViewAction::SelectToEndOfWord) => self.select_to_end_of_word(), Ok(BufferViewAction::SelectToBeginningOfLine) => self.select_to_beginning_of_line(), @@ -1301,6 +1323,27 @@ mod tests { editor.move_up(); editor.move_up(); assert_eq!(render_selections(&editor), vec![empty_selection(0, 1)]); + + // Select to a direct point in front of cursor position + editor.select_to(Point::new(1, 0)); + assert_eq!(render_selections(&editor), vec![selection((0, 1), (1, 0))]); + editor.move_right(); // cancel selection + assert_eq!(render_selections(&editor), vec![empty_selection(1, 0)]); + editor.move_right(); + editor.move_right(); + assert_eq!(render_selections(&editor), vec![empty_selection(2, 1)]); + + // Selection can even go to a point before the cursor (with reverse) + editor.select_to(Point::new(0, 0)); + assert_eq!(render_selections(&editor), vec![rev_selection((0, 0), (2, 1))]); + + // A selection can switch to a new point and the selection will update + editor.select_to(Point::new(0, 3)); + assert_eq!(render_selections(&editor), vec![rev_selection((0, 3), (2, 1))]); + + // A selection can even swing around the cursor without having to unselect + editor.select_to(Point::new(2, 3)); + assert_eq!(render_selections(&editor), vec![selection((2, 1), (2, 3))]); } #[test] diff --git a/xray_ui/lib/text_editor/text_editor.js b/xray_ui/lib/text_editor/text_editor.js index 95ae9a75..5dfda93a 100644 --- a/xray_ui/lib/text_editor/text_editor.js +++ b/xray_ui/lib/text_editor/text_editor.js @@ -9,6 +9,11 @@ const { ActionContext, Action } = require("../action_dispatcher"); const CURSOR_BLINK_RESUME_DELAY = 300; const CURSOR_BLINK_PERIOD = 800; +const MOUSE_DRAG_AUTOSCROLL_MARGIN = 40; + +function scaleMouseDragAutoscrollDelta(delta) { + return Math.pow(delta / 3, 3) / 280; +} const Root = styled("div", { width: "100%", @@ -38,7 +43,10 @@ class TextEditor extends React.Component { constructor(props) { super(props); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); this.handleMouseDown = this.handleMouseDown.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); this.handleMouseWheel = this.handleMouseWheel.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.pauseCursorBlinking = this.pauseCursorBlinking.bind(this); @@ -47,7 +55,7 @@ class TextEditor extends React.Component { CURSOR_BLINK_RESUME_DELAY ); this.paddingLeft = 5; - this.state = { scrollLeft: 0, showLocalCursors: true }; + this.state = { scrollLeft: 0, showLocalCursors: true, mouseDown: false }; } componentDidMount() { @@ -74,6 +82,13 @@ class TextEditor extends React.Component { passive: true }); + document.addEventListener("mousemove", this.handleMouseMove, { + passive: true + }); + document.addEventListener("mouseup", this.handleMouseUp, { + passive: true + }); + this.startCursorBlinking(); } @@ -83,6 +98,16 @@ class TextEditor extends React.Component { element.removeEventListener("wheel", this.handleMouseWheel, { passive: true }); + element.removeEventListener("mousedown", this.handleMouseDown, { + passive: true + }); + + document.removeEventListener("mousemove", this.handleMouseMove, { + passive: true + }); + document.removeEventListener("mouseup", this.handleMouseUp, { + passive: true + }); this.resizeObserver.disconnect(); } @@ -210,21 +235,7 @@ class TextEditor extends React.Component { ); } - handleMouseDown(event) { - if (this.canUseTextPlane()) { - this.handleClick(event); - switch (event.detail) { - case 2: - this.handleDoubleClick(); - break; - case 3: - this.handleTripleClick(); - break; - } - } - } - - handleClick({ clientX, clientY }) { + getPositionFromMouseEvent({ clientX, clientY }) { const { scroll_top, line_height, first_visible_row, lines } = this.props; const { scrollLeft } = this.state; const targetX = @@ -245,14 +256,115 @@ class TextEditor extends React.Component { break; } } + return { row, column }; + } + } - this.pauseCursorBlinking(); - this.props.dispatch({ - type: "SetCursorPosition", - row, - column, - autoscroll: false - }); + autoscrollOnMouseDrag({ clientX, clientY }) { + const top = 0 + MOUSE_DRAG_AUTOSCROLL_MARGIN; + const bottom = this.props.height - MOUSE_DRAG_AUTOSCROLL_MARGIN; + const left = 0 + MOUSE_DRAG_AUTOSCROLL_MARGIN; + const right = this.props.width - MOUSE_DRAG_AUTOSCROLL_MARGIN; + + let yDelta, yDirection; + if (clientY < top) { + yDelta = top - clientY; + yDirection = -1; + } else if (clientY > bottom) { + yDelta = clientY - bottom; + yDirection = 1; + } + + let xDelta, xDirection; + if (clientX < left) { + xDelta = left - clientX; + xDirection = -1; + } else if (clientX > right) { + xDelta = clientX - right; + xDirection = 1; + } + + if (yDelta != null) { + const scaledDelta = scaleMouseDragAutoscrollDelta(yDelta) * yDirection; + this.updateScrollTop(scaledDelta); + } + + if (xDelta != null) { + const scaledDelta = scaleMouseDragAutoscrollDelta(xDelta) * xDirection; + this.setScrollLeft(this.getScrollLeft() + scaledDelta); + } + } + + handleMouseMove(event) { + if (!this.state.mouseDown) { + return; + } + + window.requestAnimationFrame(() => { + if (this.canUseTextPlane() && this.state.mouseDown) { + const boundedPositions = { + clientX: Math.min(Math.max(event.clientX, 0), this.props.width), + clientY: Math.min(Math.max(event.clientY, 0), this.props.height) + }; + this.autoscrollOnMouseDrag(event); + const pos = this.getPositionFromMouseEvent(boundedPositions); + if (pos) { + this.props.dispatch( + Object.assign( + { + type: "SelectTo" + }, + pos + ) + ); + } + } + }); + } + + handleMouseUp() { + this.setState({ mouseDown: false }); + } + + handleMouseDown(event) { + this.setState({ mouseDown: true }); + if (this.canUseTextPlane()) { + this.handleClick(event); + switch (event.detail) { + case 2: + this.handleDoubleClick(); + break; + case 3: + this.handleTripleClick(); + break; + } + } + } + + handleClick(event) { + this.pauseCursorBlinking(); + const pos = this.getPositionFromMouseEvent(event); + if (pos) { + if (event.shiftKey) { + this.props.dispatch( + Object.assign( + { + type: "SelectTo" + }, + pos + ) + ); + } else { + this.props.dispatch( + Object.assign( + { + type: "SetCursorPosition", + autoscroll: false + }, + pos + ) + ); + } } } @@ -270,7 +382,7 @@ class TextEditor extends React.Component { if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) { this.setScrollLeft(this.state.scrollLeft + event.deltaX); } else { - this.props.dispatch({ type: "UpdateScrollTop", delta: event.deltaY }); + this.updateScrollTop(event.deltaY); } } @@ -368,6 +480,10 @@ class TextEditor extends React.Component { } } + updateScrollTop(deltaY) { + this.props.dispatch({ type: "UpdateScrollTop", delta: deltaY }); + } + setScrollLeft(scrollLeft) { this.setState({ scrollLeft: this.constrainScrollLeft(scrollLeft)