From 6d6431e54c132067482473f812d744be3118b909 Mon Sep 17 00:00:00 2001 From: MomdAli Date: Tue, 9 Sep 2025 15:42:15 +0200 Subject: [PATCH 01/11] PTBAS-738: Adjust sync window * Add tooltips * Improve dropdown menu * Fix time adjustment to use 1h steps --- .../keeptime/common/FileOpenHelper.java | 2 +- .../keeptime/controller/HeimatController.java | 38 +++- .../view/ExternalProjectsSyncController.java | 192 +++++++++++++----- .../keeptime/view/ReportController.java | 3 +- .../keeptime/viewpopup/SearchPopup.java | 161 +++++++++++++++ .../layouts/externalProjectSync.fxml | 23 +-- 6 files changed, 340 insertions(+), 79 deletions(-) create mode 100644 src/main/java/de/doubleslash/keeptime/viewpopup/SearchPopup.java diff --git a/src/main/java/de/doubleslash/keeptime/common/FileOpenHelper.java b/src/main/java/de/doubleslash/keeptime/common/FileOpenHelper.java index 03406f2b..a3613d48 100644 --- a/src/main/java/de/doubleslash/keeptime/common/FileOpenHelper.java +++ b/src/main/java/de/doubleslash/keeptime/common/FileOpenHelper.java @@ -32,7 +32,7 @@ public static boolean openFile(final String filePath) { final File file = new File(filePath); final Runtime rt = Runtime.getRuntime(); - if (!file.exists() || file.isFile()) { + if (!file.exists() || !file.isFile()) { LOG.warn("Filepath does not seem to exist or does not point to a file: '{}'.", filePath); return false; } diff --git a/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java b/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java index cd6f7aaa..8af29050 100644 --- a/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java +++ b/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java @@ -26,6 +26,8 @@ import de.doubleslash.keeptime.rest.integration.heimat.model.HeimatTask; import de.doubleslash.keeptime.rest.integration.heimat.model.HeimatTime; import de.doubleslash.keeptime.view.ProjectReport; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -145,18 +147,28 @@ public List getTableRows(final LocalDate currentReportDate, final List< pr.appendToWorkNotes(currentWorkNote); } final String keeptimeNotes = pr.getNotes(); - String canBeSyncedMessage; + TextFlow canBeSyncedMessage; + if (!isMappedInHeimat) { - canBeSyncedMessage = "Not mapped to Heimat task.\nMap in settings dialog."; + canBeSyncedMessage = new TextFlow(new Text("Not mapped to Heimat task.\nMap in settings dialog.")); } else if (heimatTasks.stream().noneMatch(ht -> ht.id() == optHeimatMapping.get().getExternalTaskId())) { - canBeSyncedMessage = "Heimat Task is not available (anymore).\nPlease check mappings in settings dialog."; + canBeSyncedMessage = new TextFlow(new Text("Heimat Task is not available (anymore).\nPlease check mappings in settings dialog.")); isMappedInHeimat = false; } else { final ExternalProjectMapping externalProjectMapping = optHeimatMapping.get(); - canBeSyncedMessage = "Sync to " + externalProjectMapping.getExternalTaskName() + "\n(" - + externalProjectMapping.getExternalProjectName() + ")"; + Text externalTaskName = new Text(externalProjectMapping.getExternalTaskName()); + externalTaskName.setStyle("-fx-font-weight: bold;"); + canBeSyncedMessage = new TextFlow(new Text("Sync to "), externalTaskName, + new Text("\n(" + externalProjectMapping.getExternalProjectName() + ")")); } + final String bookingHint = heimatTasks.stream() + .filter(ht -> ht.id() == optHeimatMapping.get().getExternalTaskId()) + .map(HeimatTask::bookingHint) + .findAny() + .orElseGet(String::new); + + if (optionalExistingMapping.isPresent()) { final Mapping existingMapping = optionalExistingMapping.get(); final ArrayList projects = new ArrayList<>(existingMapping.projects()); @@ -166,7 +178,7 @@ public List getTableRows(final LocalDate currentReportDate, final List< final boolean shouldBeSynced = isMappedInHeimat && differenceGreaterOrEqual15Minutes(heimatSeconds, keepTimeSeconds); final Mapping mapping = new Mapping(isMappedInHeimat ? optHeimatMapping.get().getExternalTaskId() : -1, - isMappedInHeimat, shouldBeSynced, canBeSyncedMessage, existingMapping.existingTimes(), projects, + isMappedInHeimat, shouldBeSynced, canBeSyncedMessage, bookingHint, existingMapping.existingTimes(), projects, existingMapping.heimatNotes(), existingMapping.keeptimeNotes() + ". " + keeptimeNotes, heimatSeconds, keepTimeSeconds); list.remove(existingMapping); @@ -176,7 +188,7 @@ public List getTableRows(final LocalDate currentReportDate, final List< isMappedInHeimat && differenceGreaterOrEqual15Minutes(heimatTimeSeconds, projectWorkSeconds); final List projects = Collections.singletonList(project); final Mapping mapping = new Mapping(isMappedInHeimat ? optHeimatMapping.get().getExternalTaskId() : -1, - isMappedInHeimat, shouldBeSynced, canBeSyncedMessage, optionalAlreadyBookedTimes, projects, + isMappedInHeimat, shouldBeSynced, canBeSyncedMessage, bookingHint, optionalAlreadyBookedTimes, projects, heimatNotes, keeptimeNotes, heimatTimeSeconds, projectWorkSeconds); list.add(mapping); } @@ -198,7 +210,7 @@ public List getTableRows(final LocalDate currentReportDate, final List< .findAny() .orElseThrow(); final Mapping mapping = new Mapping(id, true, false, - "Not mapped in KeepTime\n\n" + heimatTask.name() + "\n" + heimatTask.taskHolderName(), times, + new TextFlow(new Text("Not mapped in KeepTime\n\n" + heimatTask.name() + "\n" + heimatTask.taskHolderName())), "", times, new ArrayList<>(0), heimatNotes, "", heimatTimeSeconds, 0); list.add(mapping); }); @@ -221,9 +233,13 @@ public List getTableRows(final LocalDate currentReportDate, final List< String heimatNotes = addHeimatNotes(times); long heimatTimeSeconds = addHeimatTimes(times); + Text externalTaskName = new Text(externalProjectMapping.getExternalTaskName()); + externalTaskName.setStyle("-fx-font-weight: bold;"); + TextFlow syncMessage = new TextFlow(new Text("Present in HEIMAT but not KeepTime\n\nSync to "), externalTaskName, + new Text("\n(" + externalProjectMapping.getExternalProjectName() + ")")); + final Mapping mapping2 = new Mapping(id, true, false, - "Present in HEIMAT but not KeepTime\n\nSync to " + externalProjectMapping.getExternalTaskName() + "\n(" - + externalProjectMapping.getExternalProjectName() + ")", times, mappedProjects.stream() + syncMessage, "", times, mappedProjects.stream() .filter( mp -> mp.getExternalTaskId() == id) @@ -421,7 +437,7 @@ public ExistingAndInvalidMappings getExistingProjectMappings(List ex public record UserMapping(Mapping mapping, boolean shouldSync, String userNotes, int userMinutes) {} - public record Mapping(long heimatTaskId, boolean canBeSynced, boolean shouldBeSynced, String syncMessage, + public record Mapping(long heimatTaskId, boolean canBeSynced, boolean shouldBeSynced, TextFlow syncMessage, String bookingHint, List existingTimes, List projects, String heimatNotes, String keeptimeNotes, long heimatSeconds, long keeptimeSeconds) {} diff --git a/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java index 0f6dac2f..e5e3106c 100644 --- a/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java +++ b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java @@ -24,6 +24,7 @@ import de.doubleslash.keeptime.model.Project; import de.doubleslash.keeptime.model.Work; import de.doubleslash.keeptime.rest.integration.heimat.model.HeimatTask; +import de.doubleslash.keeptime.viewpopup.SearchPopup; import javafx.animation.Animation; import javafx.animation.KeyFrame; import javafx.animation.RotateTransition; @@ -41,13 +42,18 @@ import javafx.concurrent.Task; import javafx.fxml.FXML; import javafx.geometry.Pos; +import javafx.scene.Group; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.effect.GaussianBlur; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; import javafx.scene.layout.*; import javafx.scene.paint.Color; import javafx.scene.shape.Circle; import javafx.scene.shape.SVGPath; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; import javafx.stage.Stage; import javafx.util.Duration; import javafx.util.converter.LocalTimeStringConverter; @@ -64,6 +70,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.function.Predicate; +import java.util.stream.Collectors; import static de.doubleslash.keeptime.view.ReportController.copyToClipboard; @@ -110,9 +117,7 @@ public class ExternalProjectsSyncController { private Region syncingIconRegion; @FXML - private ComboBox heimatTaskComboBox; - @FXML - private Button addHeimatTaskButton; + private HBox heimatTaskSearchContainer; private final SVGPath loadingSpinner = SvgNodeProvider.getSvgNodeWithScale(Resources.RESOURCE.SVG_SPINNER_SOLID, 0.1, 0.1); @@ -125,7 +130,10 @@ public class ExternalProjectsSyncController { private final Color colorLoadingFailure = Color.valueOf("#c63329"); private final LocalTimeStringConverter localTimeStringConverter = new LocalTimeStringConverter(FormatStyle.MEDIUM); + + private SearchPopup heimatTaskSearchPopup; private ObservableList items; + private ObservableList itemsForBindings; private LocalDate currentReportDate; private Stage thisStage; @@ -158,9 +166,9 @@ public void initForDate(LocalDate currentReportDate, List currentWorkItems mappingTableView.setItems(items); - ObservableList items2 = FXCollections.observableArrayList( + itemsForBindings = FXCollections.observableArrayList( item -> new javafx.beans.Observable[] { item.userTimeSeconds, item.shouldSyncCheckBox, item.userNotes }); - items2.addAll(items); + itemsForBindings.addAll(items); StringBinding totalSum = Bindings.createStringBinding(() -> localTimeStringConverter.toString( LocalTime.ofSecondOfDay( items.stream().filter(item -> item.mapping.heimatTaskId() != -1L) // if its bookable in heimat @@ -169,7 +177,7 @@ public void initForDate(LocalDate currentReportDate, List currentWorkItems return item.userTimeSeconds.getValue(); else return item.heimatTimeSeconds.get(); - }).sum())), items2); + }).sum())), itemsForBindings); sumTimeLabel.textProperty().bind(totalSum); keepTimeTimeLabel.setText(localTimeStringConverter.toString( @@ -182,7 +190,7 @@ public void initForDate(LocalDate currentReportDate, List currentWorkItems boolean hasNote = !item.userNotes.get().isBlank(); boolean hasTime = areSecondsOfDayValid(item.userTimeSeconds.get()); return shouldSync && !(hasNote && hasTime); - }), items2); + }), itemsForBindings); saveButton.disableProperty().bind(projectsValidProperty); externalSystemLink.setOnAction(ae -> BrowserHelper.openURL(heimatController.getUrlForDay(currentReportDate))); @@ -190,6 +198,7 @@ public void initForDate(LocalDate currentReportDate, List currentWorkItems ae -> BrowserHelper.openURL(heimatController.getUrlForDay(currentReportDate))); final List tasksForDay = heimatController.getTasks(currentReportDate); + final FilteredList tasksNotInList = new FilteredList<>(FXCollections.observableArrayList(tasksForDay), (task) -> items.stream().noneMatch(tr -> task.id() == tr.mapping.heimatTaskId())); items.addListener((ListChangeListener) c -> { @@ -197,46 +206,43 @@ public void initForDate(LocalDate currentReportDate, List currentWorkItems tasksNotInList.setPredicate(null); tasksNotInList.setPredicate(predicate); }); - heimatTaskComboBox.setItems(tasksNotInList); - addHeimatTaskButton.disableProperty() - .bind(heimatTaskComboBox.getSelectionModel().selectedItemProperty().isNull()); - addHeimatTaskButton.setOnAction(ae -> { - final HeimatTask task = heimatTaskComboBox.getValue(); - final TableRow addedRow = new TableRow(new HeimatController.Mapping(task.id(), true, true, - "Manually added\n\nSync to " + task.name() + "\n(" + task.taskHolderName() + ")", List.of(), List.of(), - "", "", 0, 0), "", 0); + + heimatTaskSearchPopup = new SearchPopup<>(); + heimatTaskSearchPopup.setItems(tasksNotInList); + heimatTaskSearchPopup.setDisplayTextFunction( + task -> task.taskHolderName() + " - " + task.name() + ); + heimatTaskSearchPopup.setOnItemSelected(selectedTask -> { + if (selectedTask == null) return; + boolean alreadyExists = items.stream() + .anyMatch(row -> row.mapping.heimatTaskId() == selectedTask.id()); + if (alreadyExists) return; + + Text externalTaskName = new Text(selectedTask.name()); + externalTaskName.setStyle("-fx-font-weight: bold;"); + TextFlow syncMessage = new TextFlow(new Text("Manually added\n\nSync to "), externalTaskName, + new Text("\n(" + selectedTask.taskHolderName() + ")")); + + TableRow addedRow = new TableRow( + new HeimatController.Mapping( + selectedTask.id(), true, true, + syncMessage, "", + List.of(), List.of(), "", "", 0, 0 + ), + "", 0 + ); items.add(addedRow); - items2.add(addedRow); // add new row also to items2 - as it is not added automatically :( - heimatTaskComboBox.getSelectionModel().clearSelection(); - mappingTableView.scrollTo(items.size() - 1); // scroll to newly added row + itemsForBindings.add(addedRow); + mappingTableView.scrollTo(items.size() - 1); }); + heimatTaskSearchContainer.getChildren().add(heimatTaskSearchPopup.getTextField()); + heimatTaskSearchContainer.getChildren().add(heimatTaskSearchPopup.getSuggestionsButton()); + heimatTaskSearchContainer.setSpacing(3); } @FXML private void initialize() { - heimatTaskComboBox.setCellFactory(param -> new ListCell<>() { - @Override - protected void updateItem(HeimatTask item, boolean empty) { - super.updateItem(item, empty); - if (empty || item == null) { - setText(null); - } else { - setText(item.taskHolderName() + " - " + item.name()); - } - } - }); - heimatTaskComboBox.setButtonCell(new ListCell<>() { - @Override - protected void updateItem(HeimatTask item, boolean empty) { - super.updateItem(item, empty); - if (empty || item == null) { - setText(null); - } else { - setText(item.name() + " - " + item.taskHolderName()); - } - } - }); initializeLoadingScreen(); TableColumn shouldSyncColumn = new TableColumn<>("Sync"); @@ -278,7 +284,16 @@ protected void updateItem(List item, boolean empty) { setText(null); } else { VBox vbox = new VBox(5); - item.forEach(project -> vbox.getChildren().add(createRow(project.getColor(), project.getName()))); + + for (Project project : item) { + HBox row = createRow(project.getColor(), project.getName()); + vbox.getChildren().add(row); + + // Set tooltip for the label + Label label = (Label) row.getChildren().get(1); // Assuming label is second in HBox + Tooltip tooltip = new Tooltip(label.getText()); + label.setTooltip(tooltip); + } setGraphic(vbox); } } @@ -287,6 +302,9 @@ private HBox createRow(Color color, String text) { Circle circle = new Circle(6, color); Label label = new Label(text); + label.setMaxWidth(Double.MAX_VALUE); // Allow layout to constrain width + HBox.setHgrow(label, Priority.ALWAYS); // Let label grow within HBox + return new HBox(5, circle, label); } }); @@ -411,8 +429,45 @@ protected void updateItem(TableRow item, boolean empty) { } }); - TableColumn syncColumn = new TableColumn<>("Sync Status"); - syncColumn.setCellValueFactory(data -> data.getValue().syncStatus); + TableColumn syncColumn = new TableColumn<>("Sync Status"); + syncColumn.setCellValueFactory(data -> new SimpleObjectProperty<>(data.getValue())); + syncColumn.setCellFactory(column -> new TableCell<>() { + + private final Tooltip tooltip = new Tooltip(); + + @Override + protected void updateItem(TableRow item, boolean empty) { + + super.updateItem(item, empty); + + if (empty || item == null) { + setTooltip(null); + setGraphic(null); + return; + } + + TextFlow statusFlow = item.syncStatus; + String status = statusFlow.getChildren().stream() + .filter(n -> n instanceof Text) + .map(n -> ((Text) n).getText()) + .collect(Collectors.joining()); + + if (!item.bookingHint.isEmpty().get()) { + tooltip.setText(status + "\n" + item.bookingHint.get()); + setStyle("-fx-background-color: #EFEFEF"); + } + else { + tooltip.setText(status); + } + + // Fix Cell height not aligning with Textflow + // https://stackoverflow.com/questions/42855724/textflow-inside-tablecell-not-correct-cell-height + statusFlow.maxWidthProperty().bind(column.widthProperty()); + setGraphic(new Group(statusFlow)); + + setTooltip(tooltip); + } + }); shouldSyncColumn.setPrefWidth(50); projectColumn.setPrefWidth(100); @@ -579,13 +634,31 @@ private void showErrorDialog(List errorMessages) { } private void setUpTimeSpinner(final Spinner spinner) { + + BooleanProperty shiftDown = new SimpleBooleanProperty(false); + + spinner.sceneProperty().addListener((obs, oldScene, newScene) -> { + if (newScene != null) { + newScene.addEventFilter(KeyEvent.KEY_RELEASED, event -> { + if (event.getCode() == KeyCode.SHIFT) { + shiftDown.set(false); + } + }); + + newScene.addEventFilter(KeyEvent.KEY_PRESSED, event ->{ + if (event.getCode() == KeyCode.SHIFT) { + shiftDown.set(true); + } + }); + } + }); + spinner.focusedProperty().addListener(e -> { final LocalTimeStringConverter stringConverter = new LocalTimeStringConverter(FormatStyle.MEDIUM); final StringProperty text = spinner.getEditor().textProperty(); try { stringConverter.fromString(text.get()); - // needed to log in value from editor to spinner - spinner.increment(0); // TODO find better Solution + spinner.increment(0); } catch (final DateTimeParseException ex) { text.setValue(spinner.getValue().toString()); } @@ -601,9 +674,12 @@ public void decrement(final int steps) { if (steps == 0) return; final LocalTime time = getValue(); - setValue(decrementToLastFullQuarter(time)); - } + if (shiftDown.get()) + setValue(decrementToNextHour(time)); + else + setValue(decrementToLastFullQuarter(time)); + } } @Override @@ -614,11 +690,13 @@ public void increment(final int steps) { if (steps == 0) return; final LocalTime time = getValue(); - setValue(incrementToNextFullQuarter(time)); - } + if (shiftDown.get()) + setValue(incrementToNextHour(time)); + else + setValue(incrementToNextFullQuarter(time)); + } } - }); spinner.getValueFactory().setConverter(new LocalTimeStringConverter(FormatStyle.MEDIUM)); @@ -638,6 +716,14 @@ public static LocalTime incrementToNextFullQuarter(LocalTime time) { return time.plusMinutes(increment).withSecond(0).withNano(0); } + public static LocalTime incrementToNextHour(LocalTime time) { + return time.plusHours(1).withMinute(0).withSecond(0).withNano(0); + } + + public static LocalTime decrementToNextHour(LocalTime time) { + return time.minusHours(1).withMinute(0).withSecond(0).withNano(0); + } + public void setStage(final Stage thisStage) { this.thisStage = thisStage; } @@ -654,12 +740,14 @@ public static class TableRow { public final LongProperty userTimeSeconds; public final LongProperty heimatTimeSeconds; - public final StringProperty syncStatus; + public final TextFlow syncStatus; + public final StringProperty bookingHint; public TableRow(HeimatController.Mapping mapping, String userNotes, final long userSeconds) { this.mapping = mapping; this.shouldSyncCheckBox = new SimpleBooleanProperty(mapping.shouldBeSynced()); - this.syncStatus = new SimpleStringProperty(mapping.syncMessage()); + this.syncStatus = mapping.syncMessage(); + this.bookingHint = new SimpleStringProperty(mapping.bookingHint()); this.keeptimeNotes = new SimpleStringProperty(mapping.keeptimeNotes()); this.keeptimeTimeSeconds = new SimpleLongProperty(mapping.keeptimeSeconds()); diff --git a/src/main/java/de/doubleslash/keeptime/view/ReportController.java b/src/main/java/de/doubleslash/keeptime/view/ReportController.java index 5d714518..f1a3d3a9 100644 --- a/src/main/java/de/doubleslash/keeptime/view/ReportController.java +++ b/src/main/java/de/doubleslash/keeptime/view/ReportController.java @@ -25,6 +25,7 @@ import javafx.scene.Scene; import javafx.scene.image.Image; import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; import javafx.scene.layout.*; import javafx.scene.shape.SVGPath; import org.slf4j.Logger; @@ -170,7 +171,7 @@ private void showSyncStage(){ syncStage.getIcons().add(new Image(Resources.getResource(RESOURCE.ICON_MAIN).toString())); final Scene settingsScene = new Scene(syncRoot); - settingsScene.setOnKeyPressed(ke -> { + settingsScene.addEventFilter(KeyEvent.KEY_PRESSED, ke -> { if (ke.getCode() == KeyCode.ESCAPE) { LOG.info("pressed ESCAPE"); syncStage.close(); diff --git a/src/main/java/de/doubleslash/keeptime/viewpopup/SearchPopup.java b/src/main/java/de/doubleslash/keeptime/viewpopup/SearchPopup.java new file mode 100644 index 00000000..d122c1a2 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/viewpopup/SearchPopup.java @@ -0,0 +1,161 @@ +package de.doubleslash.keeptime.viewpopup; + +import javafx.beans.value.ChangeListener; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Bounds; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.TextField; +import javafx.scene.input.KeyCode; +import javafx.stage.Popup; + +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +public class SearchPopup { + private final TextField searchField = new TextField(); + private final Button showSuggestionsButton = new Button("▼"); + private final ListView suggestionList = new ListView<>(); + private final Popup popup = new Popup(); + private ObservableList allItems = FXCollections.observableArrayList(); + private Consumer selectionHandler; + private Function displayTextFunction = Object::toString; + + private ObservableList observedItemsForListener = null; + private final ListChangeListener listChangeListener = c -> filterList(searchField.getText()); + + public SearchPopup() { + popup.setAutoHide(true); + popup.getContent().add(suggestionList); + suggestionList.setMaxHeight(150); + + setupStyle(); + + showSuggestionsButton.setOnAction(ae -> { + show(searchField); + searchField.requestFocus(); + }); + + searchField.textProperty().addListener((obs, oldText, newText) -> filterList(newText)); + // Hide popup when focus is lost from both field and list + ChangeListener hidePopupListener = (obs, was, isNow) -> { + if (!searchField.isFocused() && !suggestionList.isFocused()) { + popup.hide(); + } + }; + searchField.focusedProperty().addListener((obs, wasFocused, isNowFocused) -> { + if (isNowFocused && !suggestionList.getItems().isEmpty()) { + show(searchField); + } + }); + suggestionList.focusedProperty().addListener(hidePopupListener); + + // Keyboard navigation + searchField.setOnKeyPressed(ev -> { + if (ev.getCode() == KeyCode.DOWN && !suggestionList.getItems().isEmpty()) { + show(searchField); + suggestionList.requestFocus(); + suggestionList.getSelectionModel().selectFirst(); + ev.consume(); + } + }); + suggestionList.setOnKeyPressed(ev -> { + if (ev.getCode() == KeyCode.ENTER) { + T selected = suggestionList.getSelectionModel().getSelectedItem(); + if (selected != null) { + handleSelection(selected); + popup.hide(); + } + } else if (ev.getCode() == KeyCode.UP && + suggestionList.getSelectionModel().getSelectedIndex() == 0) { + searchField.requestFocus(); + } + }); + suggestionList.setOnMouseClicked(ev -> { + T selected = suggestionList.getSelectionModel().getSelectedItem(); + if (selected != null) { + handleSelection(selected); + } + + }); + + // Custom string for suggestions + suggestionList.setCellFactory(listView -> new ListCell<>() { + @Override + protected void updateItem(T item, boolean empty) { + super.updateItem(item, empty); + setText((empty || item == null) ? null : displayTextFunction.apply(item)); + } + }); + } + + private void filterList(String input) { + String filter = (input == null) ? "" : input.trim().toLowerCase(); + ObservableList filtered = FXCollections.observableArrayList( + allItems.stream() + .filter(item -> displayTextFunction.apply(item).toLowerCase().contains(filter)) + .collect(Collectors.toList()) + ); + suggestionList.setItems(filtered); + if (!filtered.isEmpty() && searchField.isFocused()) { + show(searchField); + } else { + popup.hide(); + } + } + + private void handleSelection(T selected) { + if (selectionHandler != null) selectionHandler.accept(selected); + popup.hide(); + searchField.clear(); + } + + private void setupStyle() { + searchField.getStyleClass().add("text-field"); + showSuggestionsButton.getStyleClass().add("secondary-button"); + } + + + + public void setItems(ObservableList items) { + if (observedItemsForListener != null) { + observedItemsForListener.removeListener(listChangeListener); + } + this.allItems = items != null ? items : FXCollections.observableArrayList(); + allItems.addListener(listChangeListener); + observedItemsForListener = allItems; + filterList(searchField.getText()); + } + + public void setOnItemSelected(Consumer handler) { + this.selectionHandler = handler; + } + + public void setDisplayTextFunction(Function func) { + this.displayTextFunction = func != null ? func : Object::toString; + filterList(searchField.getText()); + } + + public TextField getTextField() { + return searchField; + } + + public Button getSuggestionsButton() { + return showSuggestionsButton; + } + + public void show(Node owner) { + if (owner == null) return; + Bounds bounds = owner.localToScreen(owner.getBoundsInLocal()); + popup.show(owner, bounds.getMinX(), bounds.getMaxY()); + } + + public void hide() { + popup.hide(); + } +} \ No newline at end of file diff --git a/src/main/resources/layouts/externalProjectSync.fxml b/src/main/resources/layouts/externalProjectSync.fxml index c88a404a..c30858d6 100644 --- a/src/main/resources/layouts/externalProjectSync.fxml +++ b/src/main/resources/layouts/externalProjectSync.fxml @@ -1,18 +1,11 @@ - - - - - - - - - - - + + + + - + @@ -54,8 +47,10 @@ - -