| /******************************************************************************* |
| * Copyright (c) 2014 BestSolution.at 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: |
| * Christoph Caks <ccaks@bestsolution.at> - improved editor behavior |
| * Tom Schindl<tom.schindl@bestsolution.at> - initial API and implementation |
| *******************************************************************************/ |
| package org.eclipse.fx.ui.controls.styledtext.skin; |
| |
| import java.util.HashMap; |
| import java.util.Map; |
| import java.util.Optional; |
| import java.util.Set; |
| import java.util.function.BiConsumer; |
| import java.util.function.Consumer; |
| import java.util.function.Function; |
| import java.util.function.Predicate; |
| import java.util.function.Supplier; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| import java.util.stream.Collectors; |
| |
| import javax.xml.transform.stream.StreamSource; |
| |
| import org.eclipse.fx.core.Subscription; |
| import org.eclipse.fx.ui.controls.styledtext.StyledTextArea; |
| import org.eclipse.fx.ui.controls.styledtext.StyledTextContent.TextChangeListener; |
| import org.eclipse.fx.ui.controls.styledtext.TextChangedEvent; |
| import org.eclipse.fx.ui.controls.styledtext.TextChangingEvent; |
| import org.eclipse.fx.ui.controls.styledtext.StyledTextArea.LineLocation; |
| import org.eclipse.fx.ui.controls.styledtext.behavior.StyledTextBehavior; |
| import org.eclipse.fx.ui.controls.styledtext.events.TextPositionEvent; |
| import org.eclipse.fx.ui.controls.styledtext.internal.ContentView; |
| import org.eclipse.fx.ui.controls.styledtext.internal.FXBindUtil; |
| import org.eclipse.fx.ui.controls.styledtext.internal.LineHelper; |
| import org.eclipse.fx.ui.controls.styledtext.internal.LineRuler; |
| import org.eclipse.fx.ui.controls.styledtext.internal.ScrollbarPane; |
| import org.eclipse.fx.ui.controls.styledtext.internal.Scroller; |
| import org.eclipse.fx.ui.controls.styledtext.internal.TextNode; |
| import org.eclipse.fx.ui.controls.styledtext.model.Annotation; |
| import org.eclipse.fx.ui.controls.styledtext.model.AnnotationPresenter; |
| import org.eclipse.fx.ui.controls.styledtext.model.AnnotationProvider; |
| import org.eclipse.fx.ui.controls.styledtext.model.LineRulerAnnotationPresenter; |
| import org.eclipse.fx.ui.controls.styledtext.model.TextAnnotationPresenter; |
| import org.eclipse.jdt.annotation.NonNull; |
| |
| import com.google.common.collect.Range; |
| import com.google.common.collect.RangeSet; |
| import com.google.common.collect.TreeRangeSet; |
| |
| import javafx.application.Platform; |
| import javafx.beans.property.IntegerProperty; |
| import javafx.collections.FXCollections; |
| import javafx.collections.ListChangeListener; |
| import javafx.collections.ObservableList; |
| import javafx.collections.SetChangeListener; |
| import javafx.collections.transformation.SortedList; |
| import javafx.event.EventHandler; |
| import javafx.geometry.Insets; |
| import javafx.geometry.Point2D; |
| import javafx.scene.Node; |
| import javafx.scene.control.ContextMenu; |
| import javafx.scene.control.SkinBase; |
| import javafx.scene.control.TextInputDialog; |
| import javafx.scene.input.ContextMenuEvent; |
| import javafx.scene.input.MouseEvent; |
| import javafx.scene.layout.HBox; |
| import javafx.scene.layout.Priority; |
| import javafx.scene.layout.Region; |
| |
| /** |
| * Styled text skin |
| */ |
| public class StyledTextSkin extends SkinBase<StyledTextArea> { |
| |
| private ScrollbarPane<ContentView> contentArea; |
| private ContentView content; |
| |
| Scroller scroller; |
| |
| private HBox lineRulerArea; |
| |
| // private ObservableList<VerticalLineFlow<Integer, Annotation>> |
| // sortedLineRulerFlows; |
| |
| private ObservableList<LineRuler> sortedLineRulerFlows; |
| |
| private HBox rootContainer; |
| |
| private final StyledTextBehavior behavior; |
| |
| private LineHelper lineHelper; |
| |
| private static final String CSS_CLASS_LINE_RULER = "line-ruler"; //$NON-NLS-1$ |
| private static final String CSS_CLASS_SPACER = "spacer"; //$NON-NLS-1$ |
| private static final String CSS_LIST_VIEW = "list-view"; //$NON-NLS-1$ |
| private SortedList<LineRulerAnnotationPresenter> sortedLineRulerPresenters; |
| |
| /** |
| * Create a new skin |
| * |
| * @param styledText |
| * the control |
| */ |
| public StyledTextSkin(StyledTextArea styledText) { |
| this(styledText, new StyledTextBehavior(styledText)); |
| } |
| |
| /** |
| * Create a new skin |
| * |
| * @param styledText |
| * the styled text |
| * @param behavior |
| * the behavior |
| */ |
| public StyledTextSkin(StyledTextArea styledText, StyledTextBehavior behavior) { |
| super(styledText); |
| this.behavior = behavior; |
| this.rootContainer = new HBox(); |
| this.rootContainer.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, e -> { |
| e.consume(); |
| }); |
| |
| this.rootContainer.setSpacing(0); |
| |
| this.lineRulerArea = new HBox(); |
| // Align with ContentView insets! |
| this.lineRulerArea.setPadding(new Insets(0,0,0,0)); |
| this.rootContainer.getChildren().add(this.lineRulerArea); |
| styledText.caretOffsetProperty().addListener((obs, ol, ne) -> { |
| int lineIdx = styledText.getContent().getLineAtOffset(ne.intValue()); |
| int colIdx = ne.intValue() - styledText.getContent().getOffsetAtLine(lineIdx); |
| |
| // fix colIdx with tabs |
| String line = styledText.getContent().getLine(lineIdx).substring(0, colIdx); |
| int tabCount = (int)line.chars().filter(c -> c == '\t').count(); |
| scrollColumnIntoView(colIdx + tabCount * (styledText.tabAvanceProperty().get() - 1), 12); |
| scrollLineIntoView(lineIdx); |
| }); |
| |
| Region spacer = new Region(); |
| spacer.getStyleClass().addAll(CSS_CLASS_LINE_RULER, CSS_CLASS_SPACER); |
| spacer.setMinWidth(2); |
| spacer.setMaxWidth(2); |
| this.rootContainer.getChildren().add(spacer); |
| |
| this.lineHelper = new LineHelper(getSkinnable()); |
| this.content = new ContentView(this.lineHelper, styledText); |
| this.content.lineHeightProperty().bind(styledText.fixedLineHeightProperty()); |
| |
| this.contentArea = new ScrollbarPane<>(); |
| |
| this.contentArea.setCenter(this.content); |
| |
| Map<AnnotationProvider, Subscription> subscriptions = new HashMap<>(); |
| Consumer<RangeSet<Integer>> onAnnotationChange = r -> { |
| this.content.updateAnnotations(r); |
| this.sortedLineRulerFlows.forEach(f -> f.update(r)); |
| }; |
| |
| getSkinnable().getAnnotationProvider().addListener((SetChangeListener<? super AnnotationProvider>) (c) -> { |
| if (c.wasAdded()) { |
| Subscription s = c.getElementAdded().registerChangeListener(onAnnotationChange); |
| subscriptions.put(c.getElementAdded(), s); |
| } |
| if (c.wasRemoved()) { |
| Subscription s = subscriptions.remove(c.getElementRemoved()); |
| if (s != null) |
| s.dispose(); |
| } |
| }); |
| for (AnnotationProvider p : getSkinnable().getAnnotationProvider()) { |
| if (!subscriptions.containsKey(p)) { |
| Subscription s = p.registerChangeListener(onAnnotationChange); |
| subscriptions.put(p, s); |
| } |
| } |
| |
| this.content.getStyleClass().addAll(CSS_LIST_VIEW); |
| this.content.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, e -> { |
| getSkinnable().getContextMenu().show(this.content, e.getScreenX(), e.getScreenY()); |
| }); |
| |
| // focus delegation |
| this.content.focusedProperty().addListener((x, o, n) -> { |
| if (n != null && n.booleanValue()) { |
| getSkinnable().requestFocus(); |
| } |
| }); |
| |
| getBehavior().installContentListeners(this.content); |
| |
| this.content.contentProperty().bind(getSkinnable().contentProperty()); |
| |
| // scroll support |
| this.content.setOnScroll((e) -> { |
| this.scroller.scrollBy(Math.round(-e.getDeltaY())); |
| }); |
| |
| // HBox.setHgrow(this.contentView, Priority.ALWAYS); |
| |
| HBox.setHgrow(this.contentArea, Priority.ALWAYS); |
| this.rootContainer.getChildren().addAll(this.contentArea); |
| getChildren().addAll(this.rootContainer); |
| |
| // scroll stuff |
| this.scroller = new Scroller(); |
| this.scroller.contentAreaHeightProperty().bind(this.content.heightProperty()); |
| this.scroller.lineHeightProperty().bind(this.content.lineHeightProperty()); |
| |
| // this.content.lineHeightProperty().set(16); |
| this.content.bindHorizontalScrollbar(this.contentArea.horizontal); |
| |
| // getSkinnable().lineCountProperty().addListener((x, o, n)-> {/* for |
| // the quantum! */}); |
| ((IntegerProperty) getSkinnable().lineCountProperty()).bind(this.content.numberOfLinesProperty()); |
| |
| // content.numberOfLinesProperty().addListener((x, o, n)-> |
| // getSkinnable().lineCountProperty().addListener((x, o, n)-> |
| |
| this.scroller.lineCountProperty().bind(this.content.numberOfLinesProperty()); |
| this.scroller.bind(this.contentArea.vertical); |
| |
| this.content.textSelectionProperty().bind(getSkinnable().selectionProperty()); |
| this.content.caretOffsetProperty().bind(getSkinnable().caretOffsetProperty()); |
| |
| this.content.visibleLinesProperty().bind(this.scroller.visibleLinesProperty()); |
| |
| // Consumer<Double> updateOffset = (offset) -> { |
| // com.google.common.collect.Range<Integer> visibleLines = this.scroller.visibleLinesProperty().get(); |
| // ContiguousSet<Integer> set = ContiguousSet.create(visibleLines, DiscreteDomain.integers()); |
| // double lineHeight = this.scroller.lineHeightProperty().get(); |
| // for (int index : set) { |
| // |
| // double y = index * lineHeight - offset.doubleValue(); |
| // |
| // for (VerticalLineFlow<Integer, Annotation> flow : this.sortedLineRulerFlows) { |
| // flow.setLineOffset(index, y); |
| // } |
| // } |
| // }; |
| |
| this.content.offsetYProperty().bind(this.scroller.offsetProperty()); |
| |
| // this.scroller.offsetProperty().addListener((x, o, offset) -> { |
| // updateOffset.accept(Double.valueOf(offset.doubleValue())); |
| // }); |
| |
| // this.scroller.visibleLinesProperty().addListener(x -> { |
| // updateOffset.accept(Double.valueOf(this.scroller.offsetProperty().get())); |
| // }); |
| |
| getSkinnable().getContent().addTextChangeListener(new TextChangeListener() { |
| |
| @Override |
| public void textSet(TextChangedEvent event) { |
| StyledTextSkin.this.scroller.refresh(); |
| } |
| |
| @Override |
| public void textChanging(TextChangingEvent event) { |
| // nothing todo |
| } |
| |
| @Override |
| public void textChanged(TextChangedEvent event) { |
| // nothing todo |
| } |
| }); |
| |
| |
| |
| ObservableList<LineRulerAnnotationPresenter> lineRulerPresenters = FXCollections.observableArrayList(); |
| |
| this.sortedLineRulerPresenters = new SortedList<>(lineRulerPresenters, (a, b) -> a.getOrder() - b.getOrder()); |
| |
| Function<LineRulerAnnotationPresenter, LineRuler> map = (ap) -> { |
| // initialize LineRuler |
| |
| Function<Integer, Set<Annotation>> converter = (index) -> this.lineHelper.getAnnotations(index.intValue()).stream().filter(ap::isApplicable).collect(Collectors.toSet()); |
| |
| Predicate<Set<Annotation>> needsPresentation = ap::isVisible; |
| Supplier<Node> nodeFactory = ap::createNode; |
| BiConsumer<Node, Set<Annotation>> populator = ap::updateNode; |
| |
| LineRuler flow = new LineRuler(ap.getLayoutHint(), converter, needsPresentation, nodeFactory, populator); |
| // VerticalLineFlow<Integer, Annotation> flow = new |
| // VerticalLineFlow<Integer, Annotation>(converter, |
| // needsPresentation, nodeFactory, populator); |
| |
| flow.visibleLinesProperty().bind(this.scroller.visibleLinesProperty()); |
| flow.numberOfLinesProperty().bind(this.content.numberOfLinesProperty()); |
| flow.lineHeightProperty().bind(this.content.lineHeightProperty()); |
| flow.yOffsetProperty().bind(this.scroller.offsetProperty()); |
| |
| // flow.getModel().bindContent(this.getModel()); |
| |
| flow.fixedWidthProperty().bind(ap.getWidth()); |
| |
| org.eclipse.fx.ui.controls.styledtext.model.LineRulerAnnotationPresenter.LineRuler lr = new LineRulerAnnotationPresenter.LineRuler() { |
| @Override |
| public Subscription subscribeMouseReleased(BiConsumer<Integer, MouseEvent> callback) { |
| EventHandler<MouseEvent> handler = e-> { |
| callback.accept(flow.findLineIndex(new Point2D(e.getX(), e.getY())), e); |
| }; |
| flow.addEventHandler(MouseEvent.MOUSE_RELEASED, handler); |
| return () -> flow.removeEventHandler(MouseEvent.MOUSE_RELEASED, handler); |
| } |
| |
| @Override |
| public Subscription subscribeMousePressed(BiConsumer<Integer, MouseEvent> callback) { |
| EventHandler<MouseEvent> handler = e-> { |
| callback.accept(flow.findLineIndex(new Point2D(e.getX(), e.getY())), e); |
| }; |
| flow.addEventHandler(MouseEvent.MOUSE_PRESSED, handler); |
| return () -> flow.removeEventHandler(MouseEvent.MOUSE_PRESSED, handler); |
| } |
| |
| @Override |
| public Subscription subscribeMouseClicked(BiConsumer<Integer, MouseEvent> callback) { |
| EventHandler<MouseEvent> handler = e-> { |
| callback.accept(flow.findLineIndex(new Point2D(e.getX(), e.getY())), e); |
| }; |
| flow.addEventHandler(MouseEvent.MOUSE_CLICKED, handler); |
| return () -> flow.removeEventHandler(MouseEvent.MOUSE_CLICKED, handler); |
| } |
| }; |
| |
| ap.initialize(lr); |
| |
| |
| return flow; |
| }; |
| |
| this.sortedLineRulerFlows = FXCollections.observableArrayList(); |
| this.sortedLineRulerFlows.addListener((ListChangeListener<LineRuler>) (c) -> { |
| for (LineRuler flow : this.sortedLineRulerFlows) { |
| flow.getStyleClass().setAll(CSS_CLASS_LINE_RULER); |
| } |
| }); |
| FXBindUtil.uniMapBindList(this.sortedLineRulerPresenters, this.sortedLineRulerFlows, map); |
| FXBindUtil.uniMapBindList(this.sortedLineRulerFlows, this.lineRulerArea.getChildren(), (flow) -> (Node) flow); |
| this.sortedLineRulerFlows.addListener((ListChangeListener<? super LineRuler>) (c) -> { |
| while (c.next()) { |
| if (c.wasRemoved()) { |
| c.getRemoved().forEach((f) -> { |
| f.visibleLinesProperty().unbind(); |
| f.numberOfLinesProperty().unbind(); |
| f.yOffsetProperty().unbind(); |
| f.fixedWidthProperty().unbind(); |
| }); |
| } |
| } |
| }); |
| |
| Consumer<AnnotationPresenter> installPresenter = (p) -> { |
| if (p instanceof LineRulerAnnotationPresenter) { |
| LineRulerAnnotationPresenter lrp = (LineRulerAnnotationPresenter) p; |
| lineRulerPresenters.add(lrp); |
| // installLineRulerAnnotationPresenter.accept(lrp); |
| } else if (p instanceof TextAnnotationPresenter) { |
| TextAnnotationPresenter tp = (TextAnnotationPresenter) p; |
| this.content.textAnnotationPresenterProperty().add(tp); |
| } |
| }; |
| Consumer<AnnotationPresenter> uninstallPresenter = (p) -> { |
| if (p instanceof LineRulerAnnotationPresenter) { |
| LineRulerAnnotationPresenter lrp = (LineRulerAnnotationPresenter) p; |
| lineRulerPresenters.remove(lrp); |
| } else if (p instanceof TextAnnotationPresenter) { |
| TextAnnotationPresenter tp = (TextAnnotationPresenter) p; |
| this.content.textAnnotationPresenterProperty().remove(tp); |
| } |
| }; |
| |
| getSkinnable().getAnnotationPresenter().addListener((SetChangeListener<? super AnnotationPresenter>) (c) -> { |
| if (c.wasAdded()) { |
| installPresenter.accept(c.getElementAdded()); |
| } |
| if (c.wasRemoved()) { |
| uninstallPresenter.accept(c.getElementRemoved()); |
| } |
| |
| // update all |
| RangeSet<Integer> r = TreeRangeSet.<Integer>create().complement(); |
| this.content.updateAnnotations(r); |
| this.sortedLineRulerFlows.forEach(f -> f.update(r)); |
| this.rootContainer.requestLayout(); |
| }); |
| getSkinnable().getAnnotationPresenter().forEach(installPresenter); |
| |
| |
| Platform.runLater( () -> { |
| scrollOffsetIntoView(getSkinnable().getCaretOffset(), 10, 12); |
| }); |
| |
| } |
| |
| public Optional<TextNode> findTextNode(Point2D screenLocation) { |
| Point2D contentLocalLocation = this.content.screenToLocal(screenLocation); |
| return this.content.findTextNode(contentLocalLocation); |
| } |
| |
| private void scrollColumnIntoView(int colIndex, int jumpAhead) { |
| double charWidth = this.content.getCharWidth(); |
| |
| double colOffset = charWidth * colIndex; |
| |
| double contentWidth = this.content.getWidth(); |
| double curOffset = this.contentArea.horizontal.getValue(); |
| |
| if (colOffset < curOffset) { |
| double jumpOffset = curOffset - jumpAhead * charWidth; |
| if (colOffset < jumpOffset) { |
| jumpOffset = colOffset; |
| } |
| double targetOffset = Math.max(this.contentArea.horizontal.getMin(), jumpOffset); |
| this.contentArea.horizontal.setValue(targetOffset); |
| |
| } |
| if (colOffset > curOffset + contentWidth) { |
| double jumpOffset = curOffset + jumpAhead * charWidth; |
| if (colOffset > jumpOffset + contentWidth) { |
| jumpOffset = colOffset + contentWidth; |
| } |
| double targetOffset = Math.min(this.contentArea.horizontal.getMax(), jumpOffset); |
| this.contentArea.horizontal.setValue(targetOffset); |
| } |
| |
| } |
| |
| public <T> Optional<T> fastQuery(String label, String fieldText, Function<String, T> converter) { |
| TextInputDialog diag = new TextInputDialog(); |
| diag.setTitle(label); |
| diag.setHeaderText(label); |
| diag.setContentText(fieldText); |
| return diag.showAndWait().map(converter); |
| } |
| |
| public void scrollLineIntoView(int lineIndex) { |
| this.scroller.scrollIntoView(lineIndex); |
| } |
| |
| public void scrollOffsetIntoView(int offset, int verticalOffset, int horizontalOffset) { |
| if( offset >= 0 ) { |
| int lineIdx = getSkinnable().getContent().getLineAtOffset(offset); |
| Range<Integer> visibleLines = this.content.getVisibleLines(); |
| if( ! visibleLines.contains(Integer.valueOf(lineIdx)) ) { |
| int linesVisible = visibleLines.upperEndpoint().intValue(); |
| int delta = linesVisible - verticalOffset; |
| int scrollLine = Math.min(lineIdx+delta, getSkinnable().getContent().getLineCount()-1); |
| scrollLineIntoView(scrollLine); |
| } |
| |
| int colIdx = offset - getSkinnable().getContent().getOffsetAtLine(lineIdx); |
| String line = getSkinnable().getContent().getLine(lineIdx).substring(0, colIdx); |
| int tabCount = (int)line.chars().filter(c -> c == '\t').count(); |
| scrollColumnIntoView(colIdx + tabCount * (getSkinnable().tabAvanceProperty().get() - 1), horizontalOffset); |
| } else { |
| scrollLineIntoView(0); |
| scrollColumnIntoView(0, 0); |
| } |
| } |
| |
| /** |
| * @return The behavior |
| */ |
| public StyledTextBehavior getBehavior() { |
| return this.behavior; |
| } |
| |
| /** |
| * The line height at the care position |
| * |
| * @param caretPosition |
| * the position |
| * @return the line height |
| */ |
| public double getLineHeight(int caretPosition) { |
| if( getSkinnable().getFixedLineHeight() >= 0.0 ) { |
| return getSkinnable().getFixedLineHeight(); |
| } |
| //FIXME: We need to calculate that size |
| return -1; |
| } |
| |
| /** |
| * Get the point for the caret position |
| * |
| * @param caretPosition |
| * the position |
| * @return the point |
| */ |
| public Point2D getCaretLocation(int caretPosition, LineLocation locationHint) { |
| if (caretPosition < 0) { |
| return null; |
| } |
| |
| Optional<Point2D> location = this.content.getLocationInScene(caretPosition, locationHint); |
| |
| return location.map(l -> this.rootContainer.sceneToLocal(l)).map(l -> new Point2D(l.getX(), l.getY() + this.content.getLineHeight())).orElse(null); |
| } |
| |
| |
| @Override |
| protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { |
| return 100; |
| } |
| |
| @Override |
| protected double computeMinHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { |
| return 60; |
| } |
| |
| /** |
| * Scroll up a line |
| */ |
| public void scrollLineUp() { |
| this.scroller.scrollBy(-1); |
| } |
| |
| /** |
| * Scroll down a line |
| */ |
| public void scrollLineDown() { |
| this.scroller.scrollBy(1); |
| } |
| |
| static String removeLineending(String s) { |
| return s.replace("\n", "").replace("\r", ""); //$NON-NLS-1$//$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ |
| } |
| |
| /** |
| * Find the offset at a specific position |
| * |
| * @param x |
| * the x coordinate |
| * @param y |
| * the y coordinate |
| * @return the offset |
| */ |
| public int getOffsetAtPosition(double x, double y) { |
| return this.content.getLineIndex(new Point2D(x, y)).orElse(Integer.valueOf(getSkinnable().getContent().getCharCount())).intValue(); |
| } |
| |
| public void refreshStyles(int start, int length) { |
| int startLine = getSkinnable().getContent().getLineAtOffset(start); |
| int endLine = getSkinnable().getContent().getLineAtOffset(start+length); |
| TreeRangeSet<Integer> set = TreeRangeSet.create(); |
| set.add(Range.closed(startLine, endLine)); |
| this.content.updatelines(set); |
| } |
| |
| public void updateInsertionMarkerIndex(int offset) { |
| this.content.updateInsertionMarkerIndex(offset); |
| } |
| |
| public int getVisibleLineCount() { |
| return scroller.visibleLineCountProperty().get(); |
| } |
| } |