-
Notifications
You must be signed in to change notification settings - Fork 236
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Refactor: create Caret
class
#429
Comments
To address multiple carets:
And other functionality such as:
As well as:
|
Aye. the |
Additionally, I think one could have access to CaretOffsetX if we chose to do that, but that's just an idea that came to mind. I don't know the full issues that might surround that. |
As in:
If those are returning the relative coordinates from the view panel, then it might be nice to have it encapsulated as:
|
@DaveJarvis
Actually, this would just be its bounds position, which is another element that would be included that I forgot to list in my previous comment. |
Ah. Consider:
Then |
Could you explain what you mean by If we did encapsulate the caret-related elements, it might also be easier to position a caret within a table. |
Yes, I meant
The definitions aren't as important as documenting them somewhere and using them consistently. |
A couple of things to keep in mind:
|
I checked to see how hard it would be to implement this. Seems a piece of cake. Below is an initial run through where the "caret" model ( package org.fxmisc.richtext.model;
import javafx.beans.value.ObservableValue;
import javafx.scene.control.IndexRange;
import org.fxmisc.richtext.model.TwoDimensional.Bias;
import org.fxmisc.richtext.model.TwoDimensional.Position;
import org.reactfx.Guard;
import org.reactfx.Subscription;
import org.reactfx.Suspendable;
import org.reactfx.value.SuspendableVal;
import org.reactfx.value.SuspendableVar;
import org.reactfx.value.Val;
import org.reactfx.value.Var;
import static org.fxmisc.richtext.model.StyledTextAreaModel.EMPTY_RANGE;
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Backward;
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Forward;
final class SelectionModel {
/**
* Private helper method.
*/
protected static int clamp(int min, int val, int max) {
return val < min ? min
: val > max ? max
: val;
}
// caret position
private final Var<Integer> internalCaretPosition = Var.newSimpleVar(0);
private final SuspendableVal<Integer> caretPosition = internalCaretPosition.suspendable();
public final int getCaretPosition() { return caretPosition.getValue(); }
public final ObservableValue<Integer> caretPositionProperty() { return caretPosition; }
// selection anchor
private final SuspendableVar<Integer> anchor = Var.newSimpleVar(0).suspendable();
public final int getAnchor() { return anchor.getValue(); }
public final ObservableValue<Integer> anchorProperty() { return anchor; }
// selection
private final Var<IndexRange> internalSelection = Var.newSimpleVar(EMPTY_RANGE);
private final SuspendableVal<IndexRange> selection = internalSelection.suspendable();
public final IndexRange getSelection() { return selection.getValue(); }
public final ObservableValue<IndexRange> selectionProperty() { return selection; }
// selected text
private final SuspendableVal<String> selectedText;
public final String getSelectedText() { return selectedText.getValue(); }
public final ObservableValue<String> selectedTextProperty() { return selectedText; }
// current paragraph index
private final SuspendableVal<Integer> currentParagraph;
public final int getCurrentParagraph() { return currentParagraph.getValue(); }
public final ObservableValue<Integer> currentParagraphProperty() { return currentParagraph; }
// caret column
private final SuspendableVal<Integer> caretColumn;
public final int getCaretColumn() { return caretColumn.getValue(); }
public final ObservableValue<Integer> caretColumnProperty() { return caretColumn; }
private Position selectionStart2D;
final Position getSelectionStart2D() { return selectionStart2D; }
private Position selectionEnd2D;
final Position getSelectionEnd2D() { return selectionEnd2D; }
private final StyledTextAreaModel<?, ?, ?> model;
private final Subscription subscription;
SelectionModel(StyledTextAreaModel<?, ?, ?> model) {
this.model = model;
Val<Position> caretPosition2D = Val.create(
() -> offsetToPosition(internalCaretPosition.getValue(), Forward),
internalCaretPosition, model.getParagraphs()
);
currentParagraph = caretPosition2D.map(Position::getMajor).suspendable();
caretColumn = caretPosition2D.map(Position::getMinor).suspendable();
selectionStart2D = position(0, 0);
selectionEnd2D = position(0, 0);
internalSelection.addListener(obs -> {
IndexRange sel = internalSelection.getValue();
selectionStart2D = offsetToPosition(sel.getStart(), Forward);
selectionEnd2D = sel.getLength() == 0
? selectionStart2D
: selectionStart2D.offsetBy(sel.getLength(), Backward);
});
selectedText = Val.create(
() -> model.getText(internalSelection.getValue()),
internalSelection, model.getParagraphs()).suspendable();
// when content is updated by an area, update the caret
// and selection ranges of all the other
// clones that also share this document
subscription = model.plainTextChanges().subscribe(plainTextChange -> {
int changeLength = plainTextChange.getInserted().length() - plainTextChange.getRemoved().length();
if (changeLength != 0) {
int indexOfChange = plainTextChange.getPosition();
// in case of a replacement: "hello there" -> "hi."
int endOfChange = indexOfChange + Math.abs(changeLength);
// update caret
int caretPosition = getCaretPosition();
if (indexOfChange < caretPosition) {
// if caret is within the changed content, move it to indexOfChange
// otherwise offset it by changeLength
positionCaret(
caretPosition < endOfChange
? indexOfChange
: caretPosition + changeLength
);
}
// update selection
int selectionStart = getSelection().getStart();
int selectionEnd = getSelection().getEnd();
if (selectionStart != selectionEnd) {
// if start/end is within the changed content, move it to indexOfChange
// otherwise, offset it by changeLength
// Note: if both are moved to indexOfChange, selection is empty.
if (indexOfChange < selectionStart) {
selectionStart = selectionStart < endOfChange
? indexOfChange
: selectionStart + changeLength;
}
if (indexOfChange < selectionEnd) {
selectionEnd = selectionEnd < endOfChange
? indexOfChange
: selectionEnd + changeLength;
}
selectRange(selectionStart, selectionEnd);
} else {
// force-update internalSelection in case caret is
// at the end of area and a character was deleted
// (prevents a StringIndexOutOfBoundsException because
// selection's end is one char farther than area's length).
int internalCaretPos = internalCaretPosition.getValue();
selectRange(internalCaretPos, internalCaretPos);
}
}
});
}
Suspendable omniSuspendable() {
return Suspendable.combine(caretPosition, anchor, selection, selectedText, currentParagraph, caretColumn);
}
Position position(int row, int col) {
return model.position(row, col);
}
Position offsetToPosition(int charOffset, Bias bias) {
return model.offsetToPosition(charOffset, bias);
}
/**
* Returns the selection range in the given paragraph.
*/
public IndexRange getParagraphSelection(int paragraph) {
int startPar = selectionStart2D.getMajor();
int endPar = selectionEnd2D.getMajor();
if(paragraph < startPar || paragraph > endPar) {
return EMPTY_RANGE;
}
int start = paragraph == startPar ? selectionStart2D.getMinor() : 0;
int end = paragraph == endPar ? selectionEnd2D.getMinor() : model.getParagraph(paragraph).length();
// force selectionProperty() to be valid
getSelection();
return new IndexRange(start, end);
}
public void selectRange(int anchor, int caretPosition) {
try(Guard g = suspend(
this.caretPosition, currentParagraph,
caretColumn, this.anchor,
selection, selectedText)) {
this.internalCaretPosition.setValue(clamp(0, caretPosition, model.getLength()));
this.anchor.setValue(clamp(0, anchor, model.getLength()));
this.internalSelection.setValue(IndexRange.normalize(getAnchor(), getCaretPosition()));
}
}
/**
* Positions only the caret. Doesn't move the anchor and doesn't change
* the selection. Can be used to achieve the special case of positioning
* the caret outside or inside the selection, as opposed to always being
* at the boundary. Use with care.
*/
public void positionCaret(int pos) {
try(Guard g = suspend(caretPosition, currentParagraph, caretColumn)) {
internalCaretPosition.setValue(pos);
}
}
protected void dispose() {
subscription.unsubscribe();
}
private Guard suspend(Suspendable... suspendables) {
return Suspendable.combine(model.suspendableBeingUpdatedProperty(), Suspendable.combine(suspendables)).suspend();
}
}
package org.fxmisc.richtext;
import javafx.scene.control.SelectionModel;
import org.fxmisc.richtext.GenericStyledArea.CaretVisibility;
import org.reactfx.value.Var;
public class SelectionView {
// ### Model-related properties delegate to model
private final SelectionModel model;
// ### View-related properties
// caret line index in paragraph
private final Var<CaretVisibility> showCaret = Var.newSimpleVar(CaretVisibility.AUTO);
public final CaretVisibility getShowCaret() { return showCaret.getValue(); }
public final void setShowCaret(CaretVisibility value) { showCaret.setValue(value); }
public final Var<CaretVisibility> showCaretProperty() { return showCaret; }
// caret blink rate
// caret bounds on screen
// selection bounds on screen
} |
Implementing #222 would be more difficult because |
Is this the correct separation of concerns? A
A Calling The
|
I was talking about the implementation details. These wouldn't be exposed in the public API but it helps keep track of things in a much easier way. See #430 as an example of what I mean.
I'd rename this to
I'm not sure if that makes much sense... isn't it the area that is doing the selection? Shouldn't the API look more like: // selects via main CaretSelectionView
public void selectRange(int from, int to)
// selects using one of the caretSelectionViews that were added previously.
public void selectRange(int from, int to, int caretSelectionIndex) Or they might need to be changed in name to always indicate whether we mean the main caretSelectionView or one of the ones we added/removed at a later time as this would allow using default methods to call // selects via main CaretSelectionView
public void selectMainRange(int from, int to)
public void selectMainRange(int fromRow, int fromCol, int toRow, int toCol)
// selects using one of the caretSelectionViews that were added previously.
public void selectAddedRange(int caretSelectionIndex, int from, int to)
public void selectAddedRange(int caretSelectionIndex, int fromRow, int fromCol, int toRow, int toCol) |
Also @DaveJarvis (since I never fully addressed this above), we use |
Another possibility is to use the inherent negative property of integers... ;-)
I was hoping that the
A public
Seems to mix concerns: a Caret isn't a Selection.
All the more reason to expose an encapsulated |
When developing against APIs like these, it feels like having to juggle a lot of mental math to keep the context of rows and columns in my head. I prefer to use objects and their relationships, rather than numerous index values. The row/col pairing, for example, could be a "position" (or "Point" or what have you), derived from a caret location. Consider:
|
True, but selectForward/Backward is easier to understand at a glance.
I think I need you to explain more of what your idealized versions of
Doesn't that mix concerns as well? ;-)
In a way, yes. A caret does not have anything to do with selection, but a selection does need a caret while also adding its own anchor to the mix. However, I'm working within the code that Tomas has already designed, so this was the quickest way to bring it all together.
So, it seems that you want |
Agreed. It will, however, lead to code duplication. That is, if I have to encode logic to determine whether to call forward/backward, someone else will likely have to write that same code. Instead, it should be captured by a single, reusable method that contains said logic in one spot. Perhaps a different name and Javadocs would help?
Ideally, I'd like to interact with the API using Position, Caret, Selection, Paragraph, and Editor objects. (I'm not sure if it makes sense to have a separate CaretModel and CaretView since this is mostly view code we're concerned about.) A Caret class could have the following behaviours:
And the following attributes:
It's delegation for convenience, and it is a slight mixing of concerns, therefore not at all necessary.
Does it need a Caret or does it need two Positions? That is, why does a Selection need to be bound, in any way, to a caret? I understand that what you've accomplished fits within the current code base, but perhaps there's a way to revise the code base that will facilitate migrating to a complete separation of these two concepts in the future? |
Unless we implemented such methods as default methods that reroute their calls to one
Perhaps that is what we should be discussing instead. So, what if we extracted the caret/selection into the view (currently, |
A great question for @TomasMikula . |
@afester Can you give us your thoughts as well? |
This was done in #463. So, I think extracting the caret and its selection would be cleaner to do now. Moreover, when I was working on this on my own local branch, I found that there might be cases where one wants a selection to depend on a caret. In such a case, |
Does it make sense to have |
I imagine |
Granted, we could still have a |
In other words: public interface Selection {}
class SelectionBase implements Selection {}
public final class UnboundedSelection extends SelectionBase {}
public final class BoundedSelection extends SelectionBase {
public BoundedSelection(Caret caret) {}
} |
Good idea! |
I think as a first step, we could refactor the code to extract the caret and selection (bounded version) to their own classes. However, we wouldn't be able to implement #222 (e.g. |
Can be:
As well:
Can be:
With On the same token, |
Where is
Why would that be needed?
How is it duplicated? |
I'm a bit of a stickler when it comes to duplication, so feel free to ignore my suggestions. :-) IMO, every requirement should be expressed once and only once within the code base. Here's another example of code duplication within the same class:
Can be:
Then, if "getSelection()" ever returns null, there's only one spot in the code to fix. Or maybe a new requirement comes in where someone wants to insert their own behaviour when the selection is empty and a pop-up is invoked.
They aren't. They'd be convenience methods defined elsewhere in the class, similar to the above example. Regarding the
Same idea can be applied:
Now the methods can be simplified to:
In the above, both Notice, too, how the number of direct references to
In this case, it might not be needed. I've run into similar situations where all I wanted to do was override a single method to inject my own behaviour. By not allowing subclasses to reuse behaviour, it can introduce tight-coupling. It could also mean the class simply has too many responsibilities and therefore the domain model is incomplete. |
The code in the
That might fall into the area Tomas mentioned about making the API more stable. In any case, assuming we did extract the caret/selection, couldn't you just wrap such an object and override the |
Consider this fictional example:
This code has eliminated the possibility for a developer to easily inject new behaviour every time the bounds for the selection is recalculated. Solutions to this problem include dependency injection, inversion of control, and factory methods, all of which can be a bit heavy. As written, it is not possible to easily add a custom Selection instance that can detect recalculation of the Bounds values. Were it written like:
Now this is possible:
The original behaviour cannot be extended to provide new functionality. For example, maybe the view port bounds are being set by software reading from a socket hooked up to remote touch screen. Also, technically, the line Using the original code, we'd have to override both methods and, worse, re-implement them:
Here, the In the ResolverYAMLGenerator code I linked to as an example, the purpose of the program is to read and write YAML files. Why does the program need to know about |
In other words, you're suggesting we implement hooks into the normal calculations so that one can easily override these values without granting access to anything else, correct? For example, given the code below: public final class BoundedSelection implements Selection {
private final Val<Bounds> boundsOnScreen
public BoundedSelection(Caret caret) {
this.caret = caret;
this.boundsOnScreen = Val.create (
() -> area.calculateBoundsOnScreenFor(this),
selectionProperty());
} rather than having public class BoundedSelection implements Selection {
private final Val<Bounds> boundsOnScreen
public BoundedSelection(Caret caret) {
this.caret = caret;
this.boundsOnScreen = Val.create (
this::recalculateBounds,
selectionProperty());
protected Bounds recalculateBounds() {
return area.calculateBoundsOnScreenFor(this);
}
} Is that correct? |
By way of example, I wanted to show how code duplication can be refactored to: (a) make shorter methods; and (b) increase the reusability and extensibility of the interface implementations. Thus:
Assigning local variables is also helpful for debugging:
|
@DaveJarvis Just FYI. I'm finishing up a semester of school and have a couple of papers due in the last few weeks, so I probably won't respond to this (much less start working on implementing what we ultimately come up with) until mid-May. |
Closed by #526 |
Coming from #417, it was proposed that we refactor the caret-related code into its own class. I've opened this issue to provide a place for discussing such a change's pros, cons, and implementation.
The text was updated successfully, but these errors were encountered: