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 5e4555ab..d6ba0af6 100644 --- a/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java +++ b/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java @@ -110,6 +110,7 @@ public List getTableRows(final LocalDate currentReportDate, final List< String heimatNotes = ""; long heimatTimeSeconds = 0; boolean isMappedInHeimat = false; + String bookingHint = ""; final Optional optHeimatMapping = mappedProjects.stream() .filter(mp -> mp.getProject().getId() == project.getId()) @@ -118,6 +119,11 @@ public List getTableRows(final LocalDate currentReportDate, final List< Optional optionalExistingMapping = Optional.empty(); if (optHeimatMapping.isPresent()) { isMappedInHeimat = true; + bookingHint = heimatTasks.stream() + .filter(ht -> ht.id() == optHeimatMapping.get().getExternalTaskId()) + .map(HeimatTask::bookingHint) + .findAny() + .orElseGet(String::new); optionalExistingMapping = list.stream() .filter(mapping -> mapping.heimatTaskId == optHeimatMapping.get() .getExternalTaskId()) @@ -145,16 +151,20 @@ public List getTableRows(final LocalDate currentReportDate, final List< pr.appendToWorkNotes(currentWorkNote); } final String keeptimeNotes = pr.getNotes(); - String canBeSyncedMessage; + StyledMessage canBeSyncedMessage; + if (!isMappedInHeimat) { - canBeSyncedMessage = "Not mapped to Heimat task.\nMap in settings dialog."; + canBeSyncedMessage = StyledMessage.of( + new StyledMessage.TextSegment("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 = StyledMessage.of(new StyledMessage.TextSegment( + "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() + ")"; + canBeSyncedMessage = StyledMessage.of(new StyledMessage.TextSegment("Sync to "), + new StyledMessage.TextSegment(externalProjectMapping.getExternalTaskName(), true), + new StyledMessage.TextSegment("\n(" + externalProjectMapping.getExternalProjectName() + ")")); } if (optionalExistingMapping.isPresent()) { @@ -166,9 +176,9 @@ 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, - existingMapping.heimatNotes(), existingMapping.keeptimeNotes() + ". " + keeptimeNotes, heimatSeconds, - keepTimeSeconds); + isMappedInHeimat, shouldBeSynced, canBeSyncedMessage, bookingHint, existingMapping.existingTimes(), + projects, existingMapping.heimatNotes(), existingMapping.keeptimeNotes() + ". " + keeptimeNotes, + heimatSeconds, keepTimeSeconds); list.remove(existingMapping); list.add(mapping); } else { @@ -176,8 +186,8 @@ 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, - heimatNotes, keeptimeNotes, heimatTimeSeconds, projectWorkSeconds); + isMappedInHeimat, shouldBeSynced, canBeSyncedMessage, bookingHint, optionalAlreadyBookedTimes, + projects, heimatNotes, keeptimeNotes, heimatTimeSeconds, projectWorkSeconds); list.add(mapping); } } @@ -193,15 +203,17 @@ public List getTableRows(final LocalDate currentReportDate, final List< long heimatTimeSeconds = times.stream() .reduce(0L, (subtotal, element) -> subtotal + element.durationInMinutes() * 60L, Long::sum); - final Optional optionalHeimatTask = heimatTasks.stream() - .filter(t -> t.id() == id) - .findAny(); + + final Optional optionalHeimatTask = heimatTasks.stream().filter(t -> t.id() == id).findAny(); String taskName = "Cannot resolve Heimat Task Id: " + id + " to name\nPlease check in Heimat"; if (optionalHeimatTask.isPresent()) { final HeimatTask heimatTask = optionalHeimatTask.get(); taskName = heimatTask.name() + "\n" + heimatTask.taskHolderName(); } - final Mapping mapping = new Mapping(id, true, false, "Not mapped in KeepTime\n\n" + taskName, times, + + final Mapping mapping = new Mapping(id, true, false, + StyledMessage.of(new StyledMessage.TextSegment("Not mapped in KeepTime\n\n" + taskName)), "", times, + new ArrayList<>(0), heimatNotes, "", heimatTimeSeconds, 0); list.add(mapping); }); @@ -224,14 +236,17 @@ public List getTableRows(final LocalDate currentReportDate, final List< String heimatNotes = addHeimatNotes(times); long heimatTimeSeconds = addHeimatTimes(times); - 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() - .filter( - mp -> mp.getExternalTaskId() - == id) - .map(ExternalProjectMapping::getProject) - .toList(), + StyledMessage syncMessage = StyledMessage.of( + new StyledMessage.TextSegment("Present in HEIMAT but not KeepTime\n\nSync to "), + new StyledMessage.TextSegment(externalProjectMapping.getExternalTaskName(), true), + new StyledMessage.TextSegment("\n(" + externalProjectMapping.getExternalProjectName() + ")")); + + final Mapping mapping2 = new Mapping(id, true, false, syncMessage, "", times, mappedProjects.stream() + .filter( + mp -> mp.getExternalTaskId() + == id) + .map(ExternalProjectMapping::getProject) + .toList(), heimatNotes, "", heimatTimeSeconds, 0); list.add(mapping2); }); @@ -424,8 +439,8 @@ 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, - List existingTimes, List projects, String heimatNotes, + public record Mapping(long heimatTaskId, boolean canBeSynced, boolean shouldBeSynced, StyledMessage syncMessage, + String bookingHint, List existingTimes, List projects, String heimatNotes, String keeptimeNotes, long heimatSeconds, long keeptimeSeconds) {} public record HeimatErrors(UserMapping mapping, String errorMessage) {} diff --git a/src/main/java/de/doubleslash/keeptime/model/StyledMessage.java b/src/main/java/de/doubleslash/keeptime/model/StyledMessage.java new file mode 100644 index 00000000..ac8471f6 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/model/StyledMessage.java @@ -0,0 +1,70 @@ +// Copyright 2025 doubleSlash Net Business GmbH +// +// This file is part of KeepTime. +// KeepTime is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package de.doubleslash.keeptime.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a styled text message composed of multiple text segments. This class provides a UI-agnostic way to + * represent formatted text, allowing separation of business logic from UI components. + */ +public class StyledMessage { + + /** + * Represents a single text segment with optional styling. + * + * @param text + * The text content + * @param bold + * Whether the text should be displayed in bold + */ + public record TextSegment(String text, boolean bold) { + public TextSegment(String text) { + this(text, false); + } + } + + private final List segments; + + public StyledMessage(List segments) { + this.segments = new ArrayList<>(segments); + } + + /** + * Creates a StyledMessage from a variable number of text segments. + * + * @param segments + * The text segments to include in the message + * @return A new StyledMessage containing the provided segments + */ + public static StyledMessage of(TextSegment... segments) { + return new StyledMessage(List.of(segments)); + } + + public List getSegments() { + return new ArrayList<>(segments); + } + + /** + * Returns the message as plain text without styling. + */ + public String toPlainText() { + return segments.stream().map(TextSegment::text).reduce("", String::concat); + } +} + diff --git a/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java index 0f6dac2f..50c844c6 100644 --- a/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java +++ b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java @@ -22,8 +22,10 @@ import de.doubleslash.keeptime.common.SvgNodeProvider; import de.doubleslash.keeptime.controller.HeimatController; import de.doubleslash.keeptime.model.Project; +import de.doubleslash.keeptime.model.StyledMessage; import de.doubleslash.keeptime.model.Work; import de.doubleslash.keeptime.rest.integration.heimat.model.HeimatTask; +import de.doubleslash.keeptime.viewpopup.SearchCombobox; import javafx.animation.Animation; import javafx.animation.KeyFrame; import javafx.animation.RotateTransition; @@ -41,13 +43,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 +71,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 +118,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); @@ -124,7 +130,10 @@ public class ExternalProjectsSyncController { private final Color colorLoadingSuccess = Color.valueOf("#74a317"); private final Color colorLoadingFailure = Color.valueOf("#c63329"); + private boolean shiftDown = false; + private final LocalTimeStringConverter localTimeStringConverter = new LocalTimeStringConverter(FormatStyle.MEDIUM); + private ObservableList items; private LocalDate currentReportDate; @@ -158,9 +167,9 @@ public void initForDate(LocalDate currentReportDate, List currentWorkItems mappingTableView.setItems(items); - ObservableList items2 = FXCollections.observableArrayList( + ObservableList 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 +178,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 +191,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 +199,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 +207,36 @@ 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); + + SearchCombobox heimatTaskSearchCombobox = new SearchCombobox<>(tasksNotInList); + heimatTaskSearchCombobox.setDisplayTextFunction(task -> task.taskHolderName() + " - " + task.name()); + + heimatTaskSearchCombobox.setOnItemSelected((selectedTask, popup) -> { + if (selectedTask == null) + return; + boolean alreadyExists = items.stream().anyMatch(row -> row.mapping.heimatTaskId() == selectedTask.id()); + if (alreadyExists) + return; + + StyledMessage syncMessage = StyledMessage.of(new StyledMessage.TextSegment("Manually added\n\nSync to "), + new StyledMessage.TextSegment(selectedTask.name(), true), + new StyledMessage.TextSegment("\n(" + selectedTask.taskHolderName() + ")")); + + TableRow addedRow = new TableRow( + new HeimatController.Mapping(selectedTask.id(), true, true, syncMessage, selectedTask.bookingHint(), 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(); + itemsForBindings.add(addedRow); // add new row also to items2 - as it is not added automatically :( mappingTableView.scrollTo(items.size() - 1); // scroll to newly added row }); + heimatTaskSearchCombobox.setClearFieldAfterSelection(true); + heimatTaskSearchContainer.getChildren().add(heimatTaskSearchCombobox.getComboBox()); + HBox.setHgrow(heimatTaskSearchCombobox.getComboBox(), Priority.ALWAYS); } @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 +278,11 @@ 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); + } setGraphic(vbox); } } @@ -286,6 +290,7 @@ protected void updateItem(List item, boolean empty) { private HBox createRow(Color color, String text) { Circle circle = new Circle(6, color); Label label = new Label(text); + label.setTooltip(new Tooltip(text)); return new HBox(5, circle, label); } @@ -411,8 +416,46 @@ 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 statusForTooltip = statusFlow.getChildren() + .stream() + .filter(Text.class::isInstance) + .map(n -> ((Text) n).getText()) + .collect(Collectors.joining()); + + final String bookingHint = item.bookingHint.get(); + if (!bookingHint.isEmpty()) { + statusFlow = new TextFlow(statusFlow); + tooltip.setText(statusForTooltip + "\nBookinghint: " + bookingHint); + Text icon = new Text(" ⓘ"); + icon.setStyle("-fx-text-fill: #1c2070; -fx-font-size: 14px;"); + statusFlow.getChildren().add(icon); + } else { + tooltip.setText(statusForTooltip); + } + + setGraphic(new Group(statusFlow)); + + setTooltip(tooltip); + } + }); shouldSyncColumn.setPrefWidth(50); projectColumn.setPrefWidth(100); @@ -422,6 +465,7 @@ protected void updateItem(TableRow item, boolean empty) { mappingTableView.getColumns().addAll(shouldSyncColumn, projectColumn, timeColumn, notesColumn, syncColumn); mappingTableView.setSelectionModel(null); + mappingTableView.setFocusTraversable(false); mappingTableView.getColumns().forEach(column -> column.setSortable(false)); saveButton.setOnAction(ae -> { @@ -579,6 +623,7 @@ private void showErrorDialog(List errorMessages) { } private void setUpTimeSpinner(final Spinner spinner) { + spinner.focusedProperty().addListener(e -> { final LocalTimeStringConverter stringConverter = new LocalTimeStringConverter(FormatStyle.MEDIUM); final StringProperty text = spinner.getEditor().textProperty(); @@ -601,9 +646,12 @@ public void decrement(final int steps) { if (steps == 0) return; final LocalTime time = getValue(); - setValue(decrementToLastFullQuarter(time)); - } + if (shiftDown) + setValue(decrementToNextHour(time)); + else + setValue(decrementToLastFullQuarter(time)); + } } @Override @@ -614,11 +662,13 @@ public void increment(final int steps) { if (steps == 0) return; final LocalTime time = getValue(); - setValue(incrementToNextFullQuarter(time)); - } + if (shiftDown) + setValue(incrementToNextHour(time)); + else + setValue(incrementToNextFullQuarter(time)); + } } - }); spinner.getValueFactory().setConverter(new LocalTimeStringConverter(FormatStyle.MEDIUM)); @@ -638,8 +688,54 @@ 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) { + if (time.getHour() == 0) + return LocalTime.MIDNIGHT; + + return time.minusHours(1).withMinute(0).withSecond(0).withNano(0); + } + public void setStage(final Stage thisStage) { this.thisStage = thisStage; + + registerKeyEventListenersForSpinners(thisStage); + } + + private void registerKeyEventListenersForSpinners(final Stage thisStage) { + thisStage.addEventFilter(KeyEvent.KEY_RELEASED, event -> { + if (event.getCode() == KeyCode.SHIFT) { + shiftDown = false; + } + }); + + thisStage.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + if (event.getCode() == KeyCode.SHIFT) { + shiftDown = true; + } + }); + } + + /** + * Converts a StyledMessage to a TextFlow for UI display. + * + * @param styledMessage + * The styled message to convert + * @return A TextFlow with properly styled text segments + */ + private static TextFlow convertStyledMessageToTextFlow(StyledMessage styledMessage) { + TextFlow textFlow = new TextFlow(); + for (StyledMessage.TextSegment segment : styledMessage.getSegments()) { + Text text = new Text(segment.text()); + if (segment.bold()) { + text.setStyle("-fx-font-weight: bold;"); + } + textFlow.getChildren().add(text); + } + return textFlow; } public static class TableRow { @@ -654,12 +750,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 = convertStyledMessageToTextFlow(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..8874360b 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; @@ -169,15 +170,15 @@ private void showSyncStage(){ syncStage.setResizable(true); syncStage.getIcons().add(new Image(Resources.getResource(RESOURCE.ICON_MAIN).toString())); - final Scene settingsScene = new Scene(syncRoot); - settingsScene.setOnKeyPressed(ke -> { + final Scene syncScene = new Scene(syncRoot); + syncScene.addEventFilter(KeyEvent.KEY_PRESSED, ke -> { if (ke.getCode() == KeyCode.ESCAPE) { LOG.info("pressed ESCAPE"); syncStage.close(); } }); - syncStage.setScene(settingsScene); + syncStage.setScene(syncScene); syncStage.showAndWait(); } catch (final Exception e) { throw new FXMLLoaderException(e); diff --git a/src/main/java/de/doubleslash/keeptime/viewpopup/SearchCombobox.java b/src/main/java/de/doubleslash/keeptime/viewpopup/SearchCombobox.java new file mode 100644 index 00000000..8e6bcc03 --- /dev/null +++ b/src/main/java/de/doubleslash/keeptime/viewpopup/SearchCombobox.java @@ -0,0 +1,258 @@ +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.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.input.KeyCode; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; +import javafx.stage.Popup; + +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +public class SearchCombobox { + 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 final HBox container; + + private ObservableList allItems = FXCollections.observableArrayList(); + private ObservableList observedItemsForListener = null; + private final ListChangeListener listChangeListener = c -> filterList(searchField.getText()); + + private Function displayTextFunction = Object::toString; + private String promptText = "Select item…"; + private double maxSuggestionHeight = 200; + + private BiConsumer> onItemSelected = (item, popup) -> {}; + private boolean clearFieldAfterSelection = false; + + public SearchCombobox(ObservableList items) { + container = new HBox(searchField, showSuggestionsButton); + container.getStyleClass().add("search-popup-container"); + container.setAlignment(Pos.CENTER_LEFT); + HBox.setHgrow(searchField, Priority.ALWAYS); + + setItems(items); + + setupUI(); + setupListeners(); + } + + private void setupUI() { + popup.setAutoHide(true); + popup.getContent().add(suggestionList); + + searchField.setPromptText(promptText); + searchField.getStyleClass().add("search-popup"); + searchField.setMaxWidth(Double.MAX_VALUE); + + showSuggestionsButton.getStyleClass().add("search-popup-button"); + + suggestionList.setMaxHeight(maxSuggestionHeight); + suggestionList.getStyleClass().add("scroll-pane"); + + suggestionList.setCellFactory(listView -> new ListCell<>() { + private final Label label = new Label(); + private final StackPane pane = new StackPane(label); + { + label.setWrapText(true); + label.setStyle("-fx-padding: 5;"); + pane.setAlignment(Pos.CENTER_LEFT); + pane.setMinWidth(0); + pane.setPrefWidth(1); + } + @Override + protected void updateItem(T item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setGraphic(null); + } else { + label.setText(displayTextFunction.apply(item)); + setGraphic(pane); + } + } + }); + } + + private void setupListeners() { + showSuggestionsButton.setOnAction(ae -> { + show(searchField); + searchField.requestFocus(); + }); + + ChangeListener hidePopupListener = (obs, was, isNow) -> { + if (!searchField.isFocused() && !suggestionList.isFocused()) popup.hide(); + }; + + searchField.focusedProperty().addListener((obs, wasFocused, isNowFocused) -> { + if (isNowFocused && !clearFieldAfterSelection) { + filterList(""); // Show all items + show(searchField); + searchField.selectAll(); // <--- This line selects all text! + } + }); + + searchField.setOnKeyPressed(ev -> { + if (ev.getCode() == KeyCode.DOWN && !suggestionList.getItems().isEmpty()) { + show(searchField); + suggestionList.requestFocus(); + suggestionList.getSelectionModel().selectFirst(); + ev.consume(); + } + }); + + searchField.setOnMouseClicked(ev -> { + if (!clearFieldAfterSelection) { + filterList(""); // Show all items + show(searchField); + searchField.selectAll(); + } else { + filterList(searchField.getText()); + show(searchField); + } + }); + + suggestionList.focusedProperty().addListener(hidePopupListener); + + suggestionList.setOnKeyPressed(ev -> { + if (ev.getCode() == KeyCode.ENTER) { + T selected = suggestionList.getSelectionModel().getSelectedItem(); + if (selected != null) handleSelection(selected); + } else if (ev.getCode() == KeyCode.UP && suggestionList.getSelectionModel().getSelectedIndex() == 0) { + searchField.requestFocus(); + } else if (ev.getCode() == KeyCode.ESCAPE) { + hide(); + container.requestFocus(); + } + }); + + suggestionList.setOnMouseClicked(ev -> { + T selected = suggestionList.getSelectionModel().getSelectedItem(); + if (selected != null) handleSelection(selected); + }); + + searchField.textProperty().addListener((obs, oldText, newText) -> { + filterList(newText); + }); + } + + 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 (clearFieldAfterSelection) { + clear(); + } else { + searchField.setText(selected == null ? "" : displayTextFunction.apply(selected)); + } + onItemSelected.accept(selected, this); + popup.hide(); + container.requestFocus(); + } + + 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 setDisplayTextFunction(Function func) { + this.displayTextFunction = func != null ? func : Object::toString; + filterList(searchField.getText()); + } + + public void setPromptText(String text) { + this.promptText = text; + searchField.setPromptText(text); + } + + public void setMaxSuggestionHeight(double height) { + this.maxSuggestionHeight = height; + suggestionList.setMaxHeight(height); + } + + public HBox getComboBox() { + return container; + } + + public void show(Node owner) { + if (owner == null || suggestionList.getItems().isEmpty()) return; + Bounds bounds = owner.localToScreen(owner.getBoundsInLocal()); + suggestionList.setPrefWidth(searchField.getWidth()); + popup.show(owner, bounds.getMinX(), bounds.getMaxY()); + } + + public void hide() { + popup.hide(); + } + + public void setSelectedItem(T item) { + if (!clearFieldAfterSelection) { + String text = (item == null) ? "" : displayTextFunction.apply(item); + searchField.setText(text); + } + } + + public T getSelectedItem() { + String text = searchField.getText(); + for (T item : allItems) { + if (displayTextFunction.apply(item).equals(text)) return item; + } + return null; + } + + public TextField getSearchField() { + return searchField; + } + + public ListView getSuggestionList() { + return suggestionList; + } + + public Button getShowSuggestionsButton() { + return showSuggestionsButton; + } + + public Function getDisplayTextFunction() { + return displayTextFunction; + } + + public void setOnItemSelected(BiConsumer> handler) { + this.onItemSelected = handler != null ? handler : (item, popup) -> {}; + } + + public void setClearFieldAfterSelection(boolean c) { + this.clearFieldAfterSelection = c; + } + + public void clear() { + searchField.clear(); + if (!promptText.isEmpty()) + searchField.setPromptText(promptText); + } +} \ No newline at end of file diff --git a/src/main/resources/css/dsStyles.css b/src/main/resources/css/dsStyles.css index dc62f23e..97ec12ac 100644 --- a/src/main/resources/css/dsStyles.css +++ b/src/main/resources/css/dsStyles.css @@ -205,4 +205,55 @@ .tree-table-row-cell:selected .tree-table-cell { -fx-border-color: #C6C6C6; -fx-border-width: 0 0.5px 0 0; +} + +.search-popup-container { + -fx-background-color: transparent; +} + +.search-popup { + -fx-background-radius: 8 0 0 8; + -fx-border-radius: 8 0 0 8; + -fx-border-color: #00759E transparent #00759E #00759E; + -fx-border-width: 1px 0 1px 1px; + -fx-background-color: white; + -fx-pref-height: 32px; + -fx-min-height: 32px; + -fx-max-height: 32px; + -fx-font-size: 13px; + -fx-text-fill: #00759E; + -fx-padding: 0 0 0 8; +} + +.search-popup:focused { + -fx-border-color: #0056b3 transparent #0056b3 #0056b3; +} + +.search-popup:hover { + -fx-border-color: #00A5E1; +} + +.search-popup-button { + -fx-background-radius: 0 8 8 0; + -fx-border-radius: 0 8 8 0; + -fx-border-color: #00759E #00759E #00759E transparent; + -fx-border-width: 1px 1px 1px 0; + -fx-background-color: white; + -fx-pref-height: 32px; + -fx-min-height: 32px; + -fx-max-height: 32px; + -fx-pref-width: 32px; + -fx-font-size: 13px; + -fx-cursor: hand; + -fx-text-fill: #00759E; + -fx-padding: 0; + -fx-content-display: center; + -fx-alignment: center; +} + +.search-popup-button:hover { + -fx-text-fill: #00A5E1; +} +.search-popup-button:pressed { + -fx-text-fill: #77DDFF; } \ No newline at end of file diff --git a/src/main/resources/layouts/externalProjectSync.fxml b/src/main/resources/layouts/externalProjectSync.fxml index c88a404a..b95a6f27 100644 --- a/src/main/resources/layouts/externalProjectSync.fxml +++ b/src/main/resources/layouts/externalProjectSync.fxml @@ -1,18 +1,10 @@ - - - - - - - - - - - + + + - + @@ -50,22 +42,17 @@ - + - - - -