Skip to content

Commit

Permalink
Improvements to the tagging system (#20)
Browse files Browse the repository at this point in the history
* Classifications are now shown in a list view
* The popup is now part of the scene, as we can otherwise get problems with renderers
  * The scene can only show on popup at a time
* Tag completion is now in a separate class that manages the completion state
* Tags are now stripped as early as possible

Removed features:
* Cell selection and correction of popup x coordinates has been removed for now as it causes problems
  • Loading branch information
Bios-Marcel authored Jul 27, 2024
1 parent bdf841b commit ee2bfcb
Show file tree
Hide file tree
Showing 13 changed files with 378 additions and 167 deletions.
3 changes: 2 additions & 1 deletion src/main/java/link/biosmarcel/baka/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ public void start(Stage stage) {
}
});

final Scene scene = new Scene(tabs, 800, 600);
final var popupPane = new PopupPane(tabs);
final Scene scene = new Scene(popupPane, 800, 600);
scene.getStylesheets().add(Objects.requireNonNull(Main.class.getResource("base.css")).toExternalForm());
stage.setTitle("Baka");
stage.getIcons().add(new Image(Objects.requireNonNull(getClass().getResourceAsStream("icon.png"))));
Expand Down
12 changes: 2 additions & 10 deletions src/main/java/link/biosmarcel/baka/data/Classification.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,11 @@ public class Classification {
/**
* Single-Tag so we can properly display a pie-chart later on. The tag will be treated case-insensitive.
*/
public String tag;
public String tag = "";

/**
* Amount, since one payment can make up multiple things. For example if you get 100€ at the bank, you
* could've spent 60€ on food and the other 40€ on weed.
*/
public BigDecimal amount;

public String getTag() {
return tag;
}

public BigDecimal getAmount() {
return amount;
}
public BigDecimal amount = BigDecimal.ZERO;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ public AutocompleteField(

@Override
Point2D computePopupLocation() {
final var textFieldBounds = input.getBoundsInParent();
final var textFieldBounds = input.localToScene(input.getBoundsInLocal());
final var caretBounds = ((TextFieldSkin) input.getSkin()).getCharacterBounds(input.getCaretPosition());
return new Point2D(textFieldBounds.getMinX() + caretBounds.getMinX(), textFieldBounds.getMaxY());
}

@Override
TextInputControl createInput() {
return new TextField();
Expand Down
67 changes: 23 additions & 44 deletions src/main/java/link/biosmarcel/baka/view/AutocompleteInput.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.StringProperty;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.TextInputControl;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.Background;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import org.jspecify.annotations.Nullable;
Expand All @@ -21,33 +20,27 @@
import java.util.function.Function;

public abstract class AutocompleteInput {
private final Pane pane;
private final ListView<String> completionList;
private final Function<String, List<String>> autocompleteGenerator;
private final char[] tokenSeparators;
private boolean insertSpaceAfterCompletion = true;

protected final TextInputControl input;

public AutocompleteInput(
final char[] tokenSeparators,
final Function<String, List<String>> autocompleteGenerator) {
pane = new Pane();
input = createInput();
completionList = new ListView<>();
this.tokenSeparators = tokenSeparators;
this.autocompleteGenerator = autocompleteGenerator;

// This is what is the Z-Index on the web. It allows us to render our popup above everything else.
pane.getChildren().add(input);
pane.getChildren().add(completionList);
pane.maxHeightProperty().bind(input.heightProperty());
pane.maxWidthProperty().bind(input.widthProperty());
input = createInput();
completionList = new ListView<>();

completionList.setFixedCellSize(35.0);
completionList.setMinHeight(completionList.getFixedCellSize() + 2);
completionList.setMaxHeight(8 * completionList.getFixedCellSize() + 2);
completionList.setBackground(Background.fill(Color.WHITE));
completionList.setFocusTraversable(false);
completionList.setManaged(false);
completionList.setVisible(false);
completionList.setCellFactory(_ -> {
final ListCell<@Nullable String> cell = new ListCell<>() {
Expand Down Expand Up @@ -130,7 +123,10 @@ private void complete() {
}

final var completable = textBeforeCaret.substring(autocompleteTo + 1);
String textToInsert = selectedItem.substring(completable.length()) + " ";
String textToInsert = selectedItem.substring(completable.length());
if (insertSpaceAfterCompletion) {
textToInsert = textToInsert + " ";
}

if (input.getSelection().getLength() > 0) {
input.replaceSelection(textToInsert);
Expand Down Expand Up @@ -164,8 +160,11 @@ private void updatePopupItems(final Collection<String> newItems) {
}

private void hidePopup() {
toBack();
completionList.setVisible(false);
final Scene scene = completionList.getScene();
if (scene == null) {
return;
}
((PopupPane) scene.getRoot()).hidePopup();
}

private void refreshPopup() {
Expand All @@ -180,31 +179,18 @@ private void refreshPopup() {
return;
}


// +2 to prevent an unnecessary scrollbar
if (completionList.getSelectionModel().getSelectedIndex() == -1) {
completionList.getSelectionModel().select(0);
}

((PopupPane) input.getScene().getRoot()).showPopup(completionList);
completionList.setPrefHeight(completionList.getItems().size() * completionList.getFixedCellSize() + 2);

final var location = computePopupLocation();
completionList.setTranslateX(location.getX());
completionList.setTranslateY(location.getY());

var bounds = completionList.localToScene(completionList.getBoundsInLocal());
final var xOutOfBounds = bounds.getMaxX() - completionList.getScene().getWidth();
if (xOutOfBounds > 0) {
completionList.setTranslateX(completionList.getTranslateX() - xOutOfBounds);
}

// We are currently not treating y out of bounds since it is a bit more complicated, as the height needs to
// be dynamic. The width is currently static ... which is yet another issue.

// Since we are using a hacky way of rendering the popup, we need to make sure it doesn't render
// behind other components, as it is part of the Pane, but outside the pane bounds.
toFront();

completionList.setVisible(true);
completionList.setLayoutX(location.getX());
completionList.setLayoutY(location.getY());
}

public StringProperty textProperty() {
Expand All @@ -229,23 +215,16 @@ public void indicateError() {
input.getStyleClass().add("text-field-error");
}

private void toFront() {
recursivelySetViewOrder(completionList, -1);
}

private void toBack() {
recursivelySetViewOrder(completionList, 0);
public void setInsertSpaceAfterCompletion(final boolean insertSpaceAfterCompletion) {
this.insertSpaceAfterCompletion = insertSpaceAfterCompletion;
}

private void recursivelySetViewOrder(final Node node, final double viewOrder) {
node.setViewOrder(viewOrder);
if (node.getParent() != null) {
recursivelySetViewOrder(node.getParent(), viewOrder);
}
public void requestFocus() {
input.requestFocus();
}

public Region getNode() {
return pane;
return input;
}

abstract Point2D computePopupLocation();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Point2D computePopupLocation() {
// Required, as the bounds will be outdated otherwise.
input.layout();

final var textFieldBounds = input.getBoundsInParent();
final var textFieldBounds = input.localToScene(input.getBoundsInLocal());
final var bounds = ((TextAreaSkin) input.getSkin()).getCaretBounds();
return new Point2D(textFieldBounds.getMinX() + bounds.getMinX(), textFieldBounds.getMinY() + bounds.getMaxY());
}
Expand Down
145 changes: 145 additions & 0 deletions src/main/java/link/biosmarcel/baka/view/ClassificationCell.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package link.biosmarcel.baka.view;

import javafx.beans.property.ObjectProperty;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.TextField;
import javafx.scene.control.TextFormatter;
import javafx.scene.layout.HBox;
import javafx.util.StringConverter;
import org.jspecify.annotations.Nullable;

import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.text.ParseException;
import java.util.function.UnaryOperator;

public class ClassificationCell extends ListCell<@Nullable ClassificationFX> {
private static final DecimalFormat CURRENCY_FORMAT;

static {
CURRENCY_FORMAT = (DecimalFormat) DecimalFormat.getNumberInstance();
CURRENCY_FORMAT.setParseBigDecimal(true);
}

private static final StringConverter<BigDecimal> CONVERTER = new StringConverter<>() {
@Override
public @Nullable BigDecimal fromString(@Nullable String value) {
// If the specified value is null or zero-length, return null
if (value == null) {
return BigDecimal.ZERO;
}

value = value.strip();
if (value.isEmpty()) {
return BigDecimal.ZERO;
}

try {
return (BigDecimal) CURRENCY_FORMAT.parse(value);
} catch (ParseException e) {
throw new RuntimeException(e);
}
}

@Override
public String toString(final @Nullable BigDecimal value) {
// If the specified value is null, return a zero-length String
if (value == null) {
return "";
}

return CURRENCY_FORMAT.format(value);
}
};

private static final UnaryOperator<TextFormatter.@Nullable Change> FILTER = change -> {
if (change == null || !change.isContentChange()) {
return change;
}

if (change.getText().chars().allMatch(value -> {
return Character.isDigit(value)
|| CURRENCY_FORMAT.getDecimalFormatSymbols().getDecimalSeparator() == value
|| CURRENCY_FORMAT.getDecimalFormatSymbols().getGroupingSeparator() == value
|| '-' == value;
})) {
return change;
}

return null;
};

private final Node renderer;
private final AutocompleteField tagField;
private final TextField amountField;
private @Nullable ClassificationFX lastItem;

public ClassificationCell(
final TagCompletion tagCompletion
) {
amountField = new TextField();

final TextFormatter<BigDecimal> formatter = new TextFormatter<>(CONVERTER, BigDecimal.ZERO, FILTER);
amountField.setTextFormatter(formatter);
amountField.textProperty().addListener((_, _, newValue) -> {
if (newValue.isEmpty()) {
return;
}

try {
amountField.getTextFormatter().getValueConverter().fromString(newValue);
amountField.getStyleClass().remove("text-field-error");
} catch (final Exception exception) {
if (!amountField.getStyleClass().contains("text-field-error")) {
amountField.getStyleClass().add("text-field-error");
}
}
});
amountField.focusedProperty().addListener((_, _, newValue) -> {
if (!newValue) {
amountField.commitValue();
}
});

tagField = new AutocompleteField(new char[]{' '}, tagCompletion::match);
tagField.setInsertSpaceAfterCompletion(false);
tagField.focusedProperty().addListener((_, _, newValue) -> {
if (!newValue) {
tagField.textProperty().set(tagField.textProperty().get().strip());
}
});

final var amountLabel = new Label("Amount:");
final var tagLabel = new Label("Tag:");
renderer = new HBox(5.0, tagLabel, tagField.getNode(), amountLabel, amountField);
amountLabel.setMaxHeight(Double.MAX_VALUE);
tagLabel.setMaxHeight(Double.MAX_VALUE);
setText(null);
}

@Override
protected void updateItem(final @Nullable ClassificationFX item, final boolean empty) {
super.updateItem(item, empty);

if (lastItem != null) {
((ObjectProperty<BigDecimal>) amountField.getTextFormatter().valueProperty()).unbindBidirectional(lastItem.amount);
tagField.textProperty().unbindBidirectional(lastItem.tag);
lastItem = null;
}

if (empty || item == null) {
setGraphic(null);
return;
}

((ObjectProperty<BigDecimal>) amountField.getTextFormatter().valueProperty()).bindBidirectional(item.amount);
tagField.textProperty().bindBidirectional(item.tag);

setGraphic(renderer);

lastItem = item;
}
}

28 changes: 28 additions & 0 deletions src/main/java/link/biosmarcel/baka/view/ClassificationFX.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package link.biosmarcel.baka.view;

import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import link.biosmarcel.baka.data.Classification;

import java.math.BigDecimal;

public class ClassificationFX {
public final Classification classification;

public final StringProperty tag = new SimpleStringProperty("");
public final ObjectProperty<BigDecimal> amount = new SimpleObjectProperty<>(BigDecimal.ZERO);

public ClassificationFX(final Classification classification) {
this.classification = classification;

tag.set(classification.tag);
amount.set(classification.amount);
}

public void apply() {
classification.amount = amount.get();
classification.tag = tag.get();
}
}
Loading

0 comments on commit ee2bfcb

Please sign in to comment.