use balanced selection

Signed-off-by: Florian Thienel <florian@thienel.org>
diff --git a/org.eclipse.vex.core.tests/src/org/eclipse/vex/core/internal/widget/BalancedSelectorTest.java b/org.eclipse.vex.core.tests/src/org/eclipse/vex/core/internal/widget/BalancedSelectorTest.java
new file mode 100644
index 0000000..1d5e29b
--- /dev/null
+++ b/org.eclipse.vex.core.tests/src/org/eclipse/vex/core/internal/widget/BalancedSelectorTest.java
@@ -0,0 +1,88 @@
+/*******************************************************************************
+ * Copyright (c) 2015 Florian Thienel and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ * 		Florian Thienel - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.vex.core.internal.widget;
+
+import static org.junit.Assert.assertEquals;
+
+import org.eclipse.vex.core.internal.io.UniversalTestDocument;
+import org.eclipse.vex.core.provisional.dom.ContentRange;
+import org.eclipse.vex.core.provisional.dom.IElement;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * @author Florian Thienel
+ */
+public class BalancedSelectorTest {
+
+	private BalancedSelector selector;
+	private UniversalTestDocument document;
+
+	@Before
+	public void setUp() throws Exception {
+		document = new UniversalTestDocument(3);
+		selector = new BalancedSelector();
+		selector.setDocument(document.getDocument());
+	}
+
+	@Test
+	public void givenMarkInText_whenSelectingOneCharForward_shouldIncludeNextChar() throws Exception {
+		final int mark = document.getOffsetWithinText(0);
+		select(mark, mark + 1);
+		assertBalancedSelectionIs(mark, mark + 1, mark + 1);
+	}
+
+	@Test
+	public void givenMarkInText_whenSelectingOneCharBackward_shouldIncludePreviousChar() throws Exception {
+		final int mark = document.getOffsetWithinText(0);
+		select(mark, mark - 1);
+		assertBalancedSelectionIs(mark - 1, mark, mark - 1);
+	}
+
+	@Test
+	public void givenMarkAtFirstTextPosition_whenSelectingOneCharBackward_shouldSelectWholeParagraph() throws Exception {
+		final IElement paragraph = document.getParagraphWithText(0);
+		select(paragraph.getStartOffset() + 1, paragraph.getStartOffset());
+		assertBalancedSelectionIs(paragraph.getStartOffset(), paragraph.getEndOffset() + 1, paragraph.getStartOffset());
+	}
+
+	@Test
+	public void givenMarkAtLastTextPosition_whenSelectingOneCharForward_shouldSelectWholeParagraph() throws Exception {
+		final IElement paragraph = document.getParagraphWithText(0);
+		select(paragraph.getEndOffset(), paragraph.getEndOffset() + 1);
+		assertBalancedSelectionIs(paragraph.getStartOffset(), paragraph.getEndOffset() + 1, paragraph.getEndOffset() + 1);
+	}
+
+	@Test
+	public void givenMarkAtStartOffsetOfEmptyParagraph_whenSelectingOneForward_shouldSelectWholeParagraph() throws Exception {
+		final IElement paragraph = document.getEmptyParagraph(0);
+		select(paragraph.getStartOffset(), paragraph.getEndOffset());
+		assertBalancedSelectionIs(paragraph.getStartOffset(), paragraph.getEndOffset() + 1, paragraph.getEndOffset() + 1);
+	}
+
+	@Test
+	public void givenMarkInText_whenSelectingToStartOffsetOfSection_shouldSelectWholeSection() throws Exception {
+		final IElement section = document.getSection(1);
+		final int mark = document.getOffsetWithinText(1);
+		select(mark, section.getStartOffset());
+		assertBalancedSelectionIs(section.getStartOffset(), section.getEndOffset() + 1, section.getStartOffset());
+	}
+
+	private void select(final int mark, final int caretPosition) {
+		selector.setMark(mark);
+		selector.moveTo(caretPosition);
+	}
+
+	private void assertBalancedSelectionIs(final int startOffset, final int endOffset, final int caretOffset) {
+		assertEquals("selection", new ContentRange(startOffset, endOffset), selector.getRange());
+		assertEquals("caret offset", caretOffset, selector.getCaretOffset());
+	}
+}
diff --git a/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/cursor/Cursor.java b/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/cursor/Cursor.java
index ffde0dc..c77e676 100644
--- a/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/cursor/Cursor.java
+++ b/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/cursor/Cursor.java
@@ -55,8 +55,8 @@
 	private int preferredX;
 	private boolean preferX;
 
-	public Cursor(final IContentSelector selection) {
-		selector = selection;
+	public Cursor(final IContentSelector selector) {
+		this.selector = selector;
 	}
 
 	public void setRootBox(final RootBox rootBox) {
diff --git a/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/io/UniversalTestDocument.java b/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/io/UniversalTestDocument.java
index 481e1e2..20f3886 100644
--- a/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/io/UniversalTestDocument.java
+++ b/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/io/UniversalTestDocument.java
@@ -22,6 +22,32 @@
 public class UniversalTestDocument {
 
 	private static final String LOREM_IPSUM_LONG = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris. Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit. Donec et mollis dolor. Praesent et diam eget libero egestas mattis sit amet vitae augue. Nam tincidunt congue enim, ut porta lorem lacinia consectetur.";
+	private final IDocument document;
+
+	public UniversalTestDocument(final int sampleCount) {
+		document = createTestDocument(sampleCount);
+	}
+
+	public IDocument getDocument() {
+		return document;
+	}
+
+	public IElement getSection(final int index) {
+		return (IElement) document.getRootElement().children().get(index);
+	}
+
+	public IElement getParagraphWithText(final int index) {
+		return (IElement) getSection(index).children().first();
+	}
+
+	public IElement getEmptyParagraph(final int index) {
+		return (IElement) getSection(index).children().last();
+	}
+
+	public int getOffsetWithinText(final int index) {
+		final IElement paragraph = getParagraphWithText(index);
+		return paragraph.getStartOffset() + 5;
+	}
 
 	public static IDocument createTestDocument(final int sampleCount) {
 		final Document document = new Document(new QualifiedName(null, "doc"));
diff --git a/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/BalancedSelector.java b/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/BalancedSelector.java
new file mode 100644
index 0000000..4778db4
--- /dev/null
+++ b/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/BalancedSelector.java
@@ -0,0 +1,115 @@
+/*******************************************************************************
+ * Copyright (c) 2015 Florian Thienel and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ * 		Florian Thienel - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.vex.core.internal.widget;
+
+import org.eclipse.vex.core.provisional.dom.IAxis;
+import org.eclipse.vex.core.provisional.dom.IDocument;
+import org.eclipse.vex.core.provisional.dom.INode;
+import org.eclipse.vex.core.provisional.dom.IParent;
+import org.eclipse.vex.core.provisional.dom.IText;
+
+/**
+ * @author Florian Thienel
+ */
+public class BalancedSelector extends BaseSelector {
+
+	private IDocument document;
+
+	public void setDocument(final IDocument document) {
+		this.document = document;
+		setMark(0);
+	}
+
+	public void moveTo(final int offset) {
+		if (document == null) {
+			return;
+		}
+
+		final boolean movingForward = offset > getCaretOffset();
+		final boolean movingBackward = offset < getCaretOffset();
+		final boolean movingTowardMark = movingForward && getMark() >= offset || movingBackward && getMark() <= offset;
+		final boolean movingAwayFromMark = !movingTowardMark;
+
+		// expand or shrink the selection to make sure the selection is balanced
+		final int balancedStart = Math.min(getMark(), offset);
+		final int balancedEnd = Math.max(getMark(), offset);
+		final INode balancedNode = document.findCommonNode(balancedStart, balancedEnd);
+		if (movingForward && movingTowardMark) {
+			setStartOffset(balanceForward(balancedStart, balancedNode));
+			setEndOffset(balanceForward(balancedEnd, balancedNode));
+			setCaretOffset(getStartOffset());
+		} else if (movingBackward && movingTowardMark) {
+			setStartOffset(balanceBackward(balancedStart, balancedNode));
+			setEndOffset(balanceBackward(balancedEnd, balancedNode));
+			setCaretOffset(getEndOffset());
+		} else if (movingForward && movingAwayFromMark) {
+			setStartOffset(balanceBackward(balancedStart, balancedNode));
+			setEndOffset(balanceForward(balancedEnd, balancedNode));
+			setCaretOffset(getEndOffset());
+		} else if (movingBackward && movingAwayFromMark) {
+			setStartOffset(balanceBackward(balancedStart, balancedNode));
+			setEndOffset(balanceForward(balancedEnd, balancedNode));
+			setCaretOffset(getStartOffset());
+		}
+	}
+
+	private int balanceForward(final int offset, final INode node) {
+		if (getParentForInsertionAt(offset) == node) {
+			return offset;
+		}
+
+		// Move the position to the start of the next node, this will insert in the parent
+		int balancedOffset = moveToNextNode(offset);
+		while (getParentForInsertionAt(balancedOffset) != node) {
+			balancedOffset = document.getChildAt(balancedOffset).getParent().getEndOffset() + 1;
+		}
+		return balancedOffset;
+	}
+
+	private int moveToNextNode(final int offset) {
+		final INode nodeAtOffset = getParentForInsertionAt(offset);
+		final IParent parent = nodeAtOffset.getParent();
+		if (parent == null) {
+			// No parent, so return the end of the current node
+			return nodeAtOffset.getEndOffset();
+		}
+		final IAxis<? extends INode> siblings = parent.children().after(offset);
+		if (!siblings.isEmpty()) {
+			return siblings.first().getStartOffset();
+		} else {
+			return parent.getEndOffset();
+		}
+	}
+
+	private INode getParentForInsertionAt(final int offset) {
+		final INode node = document.getChildAt(offset);
+		if (offset == node.getStartOffset()) {
+			return node.getParent();
+		} else if (node instanceof IText) {
+			return node.getParent();
+		} else {
+			return node;
+		}
+	}
+
+	private int balanceBackward(final int offset, final INode node) {
+		if (getParentForInsertionAt(offset) == node) {
+			return offset;
+		}
+
+		// Insertion at the start position of a node inserts into the parent
+		int balancedOffset = document.getChildAt(offset).getStartOffset();
+		while (document.getChildAt(balancedOffset).getParent() != node) {
+			balancedOffset = document.getChildAt(balancedOffset).getParent().getStartOffset();
+		}
+		return balancedOffset;
+	}
+}
diff --git a/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/swt/BoxWidget.java b/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/swt/BoxWidget.java
index bb8dabf..5aa1268 100644
--- a/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/swt/BoxWidget.java
+++ b/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/swt/BoxWidget.java
@@ -33,14 +33,13 @@
 import org.eclipse.swt.widgets.Composite;
 import org.eclipse.vex.core.internal.core.Rectangle;
 import org.eclipse.vex.core.internal.cursor.Cursor;
-import org.eclipse.vex.core.internal.cursor.IContentSelector;
 import org.eclipse.vex.core.internal.cursor.ICursorMove;
 import org.eclipse.vex.core.internal.visualization.VisualizationChain;
+import org.eclipse.vex.core.internal.widget.BalancedSelector;
 import org.eclipse.vex.core.internal.widget.BoxView;
 import org.eclipse.vex.core.internal.widget.DOMController;
 import org.eclipse.vex.core.internal.widget.IRenderer;
 import org.eclipse.vex.core.internal.widget.IViewPort;
-import org.eclipse.vex.core.internal.widget.SimpleSelector;
 import org.eclipse.vex.core.provisional.dom.IDocument;
 
 /**
@@ -51,7 +50,7 @@
 public class BoxWidget extends Canvas {
 
 	private final BoxView view;
-	private final IContentSelector selector;
+	private final BalancedSelector selector;
 	private final DOMController controller;
 
 	public BoxWidget(final Composite parent, final int style) {
@@ -67,7 +66,7 @@
 
 		final IRenderer renderer = new DoubleBufferedRenderer(this);
 		final IViewPort viewPort = new ViewPort();
-		selector = new SimpleSelector();
+		selector = new BalancedSelector();
 		final Cursor cursor = new Cursor(selector);
 
 		view = new BoxView(renderer, viewPort, cursor);
@@ -76,6 +75,7 @@
 
 	public void setContent(final IDocument document) {
 		controller.setDocument(document);
+		selector.setDocument(document);
 	}
 
 	public void setVisualizationChain(final VisualizationChain visualizationChain) {