diff --git a/src/main/java/link/biosmarcel/baka/Main.java b/src/main/java/link/biosmarcel/baka/Main.java index 8d0e925..690a0c8 100644 --- a/src/main/java/link/biosmarcel/baka/Main.java +++ b/src/main/java/link/biosmarcel/baka/Main.java @@ -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")))); diff --git a/src/main/java/link/biosmarcel/baka/data/Classification.java b/src/main/java/link/biosmarcel/baka/data/Classification.java index 15f58e5..09cdd16 100644 --- a/src/main/java/link/biosmarcel/baka/data/Classification.java +++ b/src/main/java/link/biosmarcel/baka/data/Classification.java @@ -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; } diff --git a/src/main/java/link/biosmarcel/baka/view/AutocompleteField.java b/src/main/java/link/biosmarcel/baka/view/AutocompleteField.java index ee9caeb..493f9ea 100644 --- a/src/main/java/link/biosmarcel/baka/view/AutocompleteField.java +++ b/src/main/java/link/biosmarcel/baka/view/AutocompleteField.java @@ -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(); diff --git a/src/main/java/link/biosmarcel/baka/view/AutocompleteInput.java b/src/main/java/link/biosmarcel/baka/view/AutocompleteInput.java index 8f87c18..13e4156 100644 --- a/src/main/java/link/biosmarcel/baka/view/AutocompleteInput.java +++ b/src/main/java/link/biosmarcel/baka/view/AutocompleteInput.java @@ -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; @@ -21,33 +20,27 @@ import java.util.function.Function; public abstract class AutocompleteInput { - private final Pane pane; private final ListView completionList; private final Function> autocompleteGenerator; private final char[] tokenSeparators; + private boolean insertSpaceAfterCompletion = true; protected final TextInputControl input; public AutocompleteInput( final char[] tokenSeparators, final Function> 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<>() { @@ -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); @@ -164,8 +160,11 @@ private void updatePopupItems(final Collection newItems) { } private void hidePopup() { - toBack(); - completionList.setVisible(false); + final Scene scene = completionList.getScene(); + if (scene == null) { + return; + } + ((PopupPane) scene.getRoot()).hidePopup(); } private void refreshPopup() { @@ -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() { @@ -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(); diff --git a/src/main/java/link/biosmarcel/baka/view/AutocompleteTextArea.java b/src/main/java/link/biosmarcel/baka/view/AutocompleteTextArea.java index 5ad223c..e47febd 100644 --- a/src/main/java/link/biosmarcel/baka/view/AutocompleteTextArea.java +++ b/src/main/java/link/biosmarcel/baka/view/AutocompleteTextArea.java @@ -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()); } diff --git a/src/main/java/link/biosmarcel/baka/view/ClassificationCell.java b/src/main/java/link/biosmarcel/baka/view/ClassificationCell.java new file mode 100644 index 0000000..9a658f0 --- /dev/null +++ b/src/main/java/link/biosmarcel/baka/view/ClassificationCell.java @@ -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 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 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 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) amountField.getTextFormatter().valueProperty()).unbindBidirectional(lastItem.amount); + tagField.textProperty().unbindBidirectional(lastItem.tag); + lastItem = null; + } + + if (empty || item == null) { + setGraphic(null); + return; + } + + ((ObjectProperty) amountField.getTextFormatter().valueProperty()).bindBidirectional(item.amount); + tagField.textProperty().bindBidirectional(item.tag); + + setGraphic(renderer); + + lastItem = item; + } +} + diff --git a/src/main/java/link/biosmarcel/baka/view/ClassificationFX.java b/src/main/java/link/biosmarcel/baka/view/ClassificationFX.java new file mode 100644 index 0000000..1f0074e --- /dev/null +++ b/src/main/java/link/biosmarcel/baka/view/ClassificationFX.java @@ -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 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(); + } +} diff --git a/src/main/java/link/biosmarcel/baka/view/ClassificationsView.java b/src/main/java/link/biosmarcel/baka/view/ClassificationsView.java index 72f8eb0..66069f9 100644 --- a/src/main/java/link/biosmarcel/baka/view/ClassificationsView.java +++ b/src/main/java/link/biosmarcel/baka/view/ClassificationsView.java @@ -21,14 +21,16 @@ public class ClassificationsView extends BakaTab { private final ListView listView; private final TextField nameField; - private final TextField tagField; + private final AutocompleteField tagField; private final AutocompleteTextArea queryField; private final ReadOnlyObjectProperty<@Nullable ClassificationRuleFX> selectedRuleProperty; + private final TagCompletion tagCompletion; public ClassificationsView(ApplicationState state) { super("Classifications", state); + tagCompletion = new TagCompletion(state); listView = new ListView<>(); // FIXME Abstract this away for when we have multiple list views. @@ -47,7 +49,8 @@ protected void updateItem(@Nullable ClassificationRuleFX item, boolean empty) { selectedRuleProperty = listView.getSelectionModel().selectedItemProperty(); nameField = new TextField(); - tagField = new TextField(); + tagField = new AutocompleteField(new char[]{' '}, tagCompletion::match); + tagField.setInsertSpaceAfterCompletion(false); final PaymentFilter filter = new PaymentFilter(); queryField = new AutocompleteTextArea( @@ -85,8 +88,8 @@ protected void updateItem(@Nullable ClassificationRuleFX item, boolean empty) { oldValue.tag.unbindBidirectional(tagField.textProperty()); oldValue.query.unbindBidirectional(queryField.textProperty()); - nameField.setText(""); - tagField.setText(""); + nameField.textProperty().set(""); + tagField.textProperty().set(""); queryField.textProperty().set(""); } @@ -95,6 +98,8 @@ protected void updateItem(@Nullable ClassificationRuleFX item, boolean empty) { tagField.textProperty().bindBidirectional(newValue.tag); queryField.textProperty().bindBidirectional(newValue.query); } + + tagCompletion.update(); }); nameField.disableProperty().bind(disableInputs); tagField.disableProperty().bind(disableInputs); @@ -104,7 +109,7 @@ protected void updateItem(@Nullable ClassificationRuleFX item, boolean empty) { details.add(new Label("Name"), 0, 0); details.add(nameField, 1, 0); details.add(new Label("Tag"), 0, 1); - details.add(tagField, 1, 1); + details.add(tagField.getNode(), 1, 1); details.add(new Label("Query"), 0, 2); details.add(queryField.getNode(), 1, 2); @@ -197,6 +202,8 @@ private List convertRules(final Collection activePayment = new SimpleObjectProperty<>(); public final BooleanProperty disableComponents = new SimpleBooleanProperty(true); - private final BooleanBinding disableDelete; // Do not inline, it will get garbage collected! - private final ApplicationState state; - - public PaymentDetails(final ApplicationState state) { - this.state = state; - - final TableView classificationsTable = new TableView<>(); - classificationsTable.setEditable(true); - - final TableColumn amountColumn = new TableColumn<>("Amount"); - final Callback, TableCell> - amountColumnCellFactory = _ -> - new TextFieldTableCell<>(new StringConverter() { - @Override - public @Nullable String toString(final @Nullable BigDecimal value) { - if (value == null) { - return null; - } - return value.toString(); - } - - @Override - public @Nullable BigDecimal fromString(final @Nullable String string) { - if (string == null || string.isBlank()) { - return null; - } - return new BigDecimal(string); - } - }); - amountColumn.setCellFactory(amountColumnCellFactory); - amountColumn.setCellValueFactory(new PropertyValueFactory<>("amount")); - amountColumn.setOnEditCommit(event -> { - event.getRowValue().amount = event.getNewValue(); - state.storer.store(event.getRowValue()); - state.storer.commit(); - }); - - final TableColumn tagColumn = new TableColumn<>("Tag"); - final Callback, TableCell> - simpleStringColumnFactory = _ -> - new TextFieldTableCell<>(new StringConverter() { - @Override - public @Nullable String toString(final @Nullable String string) { - return string; - } - - @Override - public @Nullable String fromString(final @Nullable String string) { - return string; - } - }); - tagColumn.setCellFactory(simpleStringColumnFactory); - tagColumn.setCellValueFactory(new PropertyValueFactory<>("tag")); - tagColumn.setOnEditCommit(event -> { - event.getRowValue().tag = event.getNewValue(); - final var payment = Objects.requireNonNull(activePayment.get()); - state.storer.store(event.getRowValue()); - - // HACK Small trick to make sure we rerender the table cell. Not quite the best way, but it'll do for now. - final var old = new ArrayList<>(payment.classifications); - payment.classifications.clear(); - payment.classifications.setAll(old); - state.storer.commit(); - }); - - classificationsTable.getColumns().addAll( - amountColumn, - tagColumn - ); + public PaymentDetails(final TagCompletion tagCompletion) { + final var classificationsList = new ListView(); + classificationsList.disableProperty().bind(disableComponents); + classificationsList.setCellFactory(_ -> new ClassificationCell(tagCompletion)); - classificationsTable.disableProperty().bind(disableComponents); + activePayment.addListener((_, oldValue, newValue) -> { + if (oldValue != null) { + oldValue.classifications.forEach(ClassificationFX::apply); + oldValue.classificationRenderValue.invalidate(); + } - activePayment.addListener((_, _, newValue) -> { if (newValue == null) { + classificationsList.setItems(FXCollections.emptyObservableList()); disableComponents.set(true); - classificationsTable.setItems(FXCollections.emptyObservableList()); } else { disableComponents.set(false); - classificationsTable.setItems(newValue.classifications); + classificationsList.setItems(newValue.classifications); } - }); + tagCompletion.update(); + }); final var createButton = new Button("New"); createButton.disableProperty().bind(disableComponents); @@ -119,29 +49,33 @@ public PaymentDetails(final ApplicationState state) { newClassification.amount = payment.amount.get().abs(); payment.payment.classifications.add(newClassification); - payment.classifications.add(newClassification); - - state.storer.store(payment.payment.classifications); - state.storer.commit(); + final ClassificationFX newClassificationFX = new ClassificationFX(newClassification); + payment.classifications.add(newClassificationFX); + + classificationsList.getSelectionModel().select(newClassificationFX); + classificationsList.layout(); + classificationsList + .lookupAll(".cell") + .stream() + .map(node -> (ClassificationCell) node) + .filter(Cell::isSelected) + .findFirst() + .ifPresent(ClassificationCell::requestFocus); }); final var deleteButton = new Button("Delete"); - final ReadOnlyObjectProperty<@Nullable Classification> selectedClassifiction = - classificationsTable.getSelectionModel().selectedItemProperty(); - disableDelete = Bindings.createBooleanBinding( - () -> disableComponents.get() || selectedClassifiction.get() == null, - disableComponents, selectedClassifiction); + final ReadOnlyObjectProperty<@Nullable ClassificationFX> selectedClassification = + classificationsList.getSelectionModel().selectedItemProperty(); + final var disableDelete = Bindings.createBooleanBinding( + () -> disableComponents.get() || selectedClassification.get() == null, + disableComponents, selectedClassification); deleteButton.disableProperty().bind(disableDelete); - final ReadOnlyObjectProperty<@Nullable Classification> selectedClassification = - classificationsTable.getSelectionModel().selectedItemProperty(); deleteButton.setOnAction(_ -> { final var selected = Objects.requireNonNull(selectedClassification.get()); final var payment = Objects.requireNonNull(activePayment.get()); payment.classifications.remove(selected); - payment.payment.classifications.remove(selected); - state.storer.store(payment.payment); - state.storer.commit(); + payment.payment.classifications.remove(selected.classification); }); final var buttons = new HBox( @@ -150,10 +84,19 @@ public PaymentDetails(final ApplicationState state) { ); buttons.setSpacing(5.0); + classificationsList.setMaxWidth(Double.MAX_VALUE); setSpacing(5.0); getChildren().addAll( buttons, - classificationsTable + classificationsList ); } + + public void save() { + final var selected = activePayment.get(); + if (selected != null) { + selected.classifications.forEach(ClassificationFX::apply); + selected.classificationRenderValue.invalidate(); + } + } } diff --git a/src/main/java/link/biosmarcel/baka/view/PaymentFX.java b/src/main/java/link/biosmarcel/baka/view/PaymentFX.java index 753ab11..f4a00bf 100644 --- a/src/main/java/link/biosmarcel/baka/view/PaymentFX.java +++ b/src/main/java/link/biosmarcel/baka/view/PaymentFX.java @@ -15,6 +15,9 @@ import java.math.BigDecimal; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; /** * Data Model for the {@link PaymentsView}-Tab. @@ -31,11 +34,11 @@ public class PaymentFX { public final ObjectProperty bookingDate = new SimpleObjectProperty<>(); public final ObjectProperty<@Nullable LocalDate> effectiveDate = new SimpleObjectProperty<>(); - public final ObservableList classifications = FXCollections.observableArrayList(); + public final ObservableList classifications = FXCollections.observableArrayList(); public final StringBinding classificationRenderValue = Bindings.createStringBinding(() -> { return classifications .stream() - .map(classification -> classification.tag) + .map(classification -> classification.tag.get()) .reduce((string, string2) -> string + "; " + string2) .orElse(""); }, classifications); @@ -51,6 +54,14 @@ public PaymentFX(final Payment payment) { if (payment.effectiveDate != null) { effectiveDate.set(payment.effectiveDate.toLocalDate()); } - classifications.addAll(payment.classifications); + classifications.addAll(convertClassifications(payment.classifications)); + } + + private static List convertClassifications(Collection classifications) { + final var converted = new ArrayList(classifications.size()); + for (final var classification : classifications) { + converted.add(new ClassificationFX(classification)); + } + return converted; } } diff --git a/src/main/java/link/biosmarcel/baka/view/PaymentsView.java b/src/main/java/link/biosmarcel/baka/view/PaymentsView.java index 5889038..0f7f3b5 100644 --- a/src/main/java/link/biosmarcel/baka/view/PaymentsView.java +++ b/src/main/java/link/biosmarcel/baka/view/PaymentsView.java @@ -37,10 +37,12 @@ public class PaymentsView extends BakaTab { private final MenuButton importButton; private final ObservableList data = FXCollections.observableArrayList(); private final FilteredList filteredData = new FilteredList<>(data); + private final TagCompletion tagCompletion; public PaymentsView(ApplicationState state) { super("Payments", state); + this.tagCompletion = new TagCompletion(state); this.table = new TableView<>(); final TableColumn amountColumn = new TableColumn<>("Amount"); @@ -98,7 +100,7 @@ public PaymentsView(ApplicationState state) { table.sort(); }); - details = new PaymentDetails(state); + details = new PaymentDetails(tagCompletion); final var filterField = new AutocompleteField( new char[]{')', '(', ' ', '\n'}, @@ -212,6 +214,7 @@ private static List convertPayments(final Collection payment protected void onTabActivated() { data.setAll(convertPayments(state.data.payments)); table.sort(); + tagCompletion.update(); details.activePayment.bind(table.getSelectionModel().selectedItemProperty()); @@ -236,5 +239,12 @@ protected void onTabDeactivated() { data.clear(); importButton.getItems().clear(); } + + @Override + public void save() { + details.save(); + state.storer.store(state.data); + state.storer.commit(); + } } diff --git a/src/main/java/link/biosmarcel/baka/view/PopupPane.java b/src/main/java/link/biosmarcel/baka/view/PopupPane.java new file mode 100644 index 0000000..9dd4f84 --- /dev/null +++ b/src/main/java/link/biosmarcel/baka/view/PopupPane.java @@ -0,0 +1,43 @@ +package link.biosmarcel.baka.view; + +import javafx.scene.Node; +import javafx.scene.layout.AnchorPane; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; + +public class PopupPane extends AnchorPane { + private @Nullable Node popup; + + public PopupPane(final Node center) { + getChildren().add(center); + AnchorPane.setBottomAnchor(center, 0.0); + AnchorPane.setLeftAnchor(center, 0.0); + AnchorPane.setRightAnchor(center, 0.0); + AnchorPane.setTopAnchor(center, 0.0); + } + + public void hidePopup() { + if (popup == null) { + return; + } + + getChildren().remove(popup); + popup.setManaged(false); + popup.setVisible(false); + popup = null; + } + + public void showPopup(final Node popup) { + if (!Objects.equals(popup, this.popup)) { + hidePopup(); + + getChildren().add(popup); + this.popup = popup; + } + + popup.setViewOrder(-1); + popup.setManaged(true); + popup.setVisible(true); + } +} diff --git a/src/main/java/link/biosmarcel/baka/view/TagCompletion.java b/src/main/java/link/biosmarcel/baka/view/TagCompletion.java new file mode 100644 index 0000000..0948474 --- /dev/null +++ b/src/main/java/link/biosmarcel/baka/view/TagCompletion.java @@ -0,0 +1,46 @@ +package link.biosmarcel.baka.view; + +import link.biosmarcel.baka.ApplicationState; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class TagCompletion { + private final Set availableTags = new HashSet<>(); + private final ApplicationState state; + + public TagCompletion(final ApplicationState state) { + this.state = state; + } + + private void addTagsFromRules() { + for (final var rule : state.data.classificationRules) { + if (!rule.tag.isBlank()) { + // We temporarily strip here, as the final stripping happens on save. + availableTags.add(rule.tag.strip().toLowerCase()); + } + } + } + + private void addTagsFromPayments() { + for (final var payment : state.data.payments) { + for (final var classification : payment.classifications) { + if (!classification.tag.isBlank()) { + availableTags.add(classification.tag.toLowerCase()); + } + } + } + } + + public void update() { + availableTags.clear(); + addTagsFromPayments(); + addTagsFromRules(); + } + + public List match(final String text) { + final var lowered = text.toLowerCase().stripLeading(); + return availableTags.stream().filter(tag -> tag.startsWith(lowered) && !tag.equals(lowered)).toList(); + } +}