diff --git a/xray_core/src/buffer_view.rs b/xray_core/src/buffer_view.rs index b62f134c..33f88509 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, @@ -509,6 +513,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() @@ -1086,6 +1104,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(), @@ -1300,6 +1322,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..344486d8 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,6 +43,8 @@ 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.handleMouseWheel = this.handleMouseWheel.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); @@ -47,7 +54,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 +81,26 @@ class TextEditor extends React.Component { passive: true }); + let lastMousemoveEvent + const animationFrameLoop = () => { + window.requestAnimationFrame(() => { + if (this.state.mouseDown) { + this.handleMouseMove(lastMousemoveEvent) + animationFrameLoop() + } + }) + } + + document.addEventListener("mousemove", event => { + lastMousemoveEvent = event; + animationFrameLoop() + }, { + passive: true + }); + document.addEventListener("mouseup", this.handleMouseUp, { + passive: true + }); + this.startCursorBlinking(); } @@ -210,21 +237,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 +258,94 @@ 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.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 +363,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 +461,10 @@ class TextEditor extends React.Component { } } + updateScrollTop(deltaY) { + this.props.dispatch({ type: "UpdateScrollTop", delta: deltaY }); + } + setScrollLeft(scrollLeft) { this.setState({ scrollLeft: this.constrainScrollLeft(scrollLeft)