diff --git a/README.md b/README.md index 178f7c9..93da7e4 100644 --- a/README.md +++ b/README.md @@ -1062,6 +1062,19 @@ Storage directory is created automatically at startup in the `storage/` folder r #### Exposed Spring Beans - **`StorageService`**: Main interface for storing and loading data by ID or path +#### Reusable File Selection Dialog + +The engine includes a reusable `FileSelectionDialogModule` for browsing, selecting, creating, renaming, and deleting stored resources. It is a generic UI module built on the module and container systems, so applications can embed the same file picker flow for load, save, and management scenarios without duplicating dialog logic. + +Key capabilities: +- **Modes**: `LOAD`, `SAVE`, and `MANAGE` +- **Path navigation**: Browse virtual or stored directories, move up the hierarchy, and keep the current path in sync with the active storage context +- **Filtering**: Optional pattern-based searching for narrow file discovery +- **Creation and management**: Create folders, rename selected items, delete selected items, and resolve a final path/name for the selected resource +- **Result contract**: Emits a structured `FileSelectionDialogResponse` event with the request ID, selection state, resource, path, and existing flag + +The dialog is designed for storage-backed workflows and can be opened with a parent window, an initial name, and optional modal behavior. This makes it a reusable library component for save-game prompts, configuration import/export, and other file-oriented UI flows. + --- ### Resource Management diff --git a/src/main/java/de/extio/game_engine/storage/dialog/FileSelectionDialogModule.java b/src/main/java/de/extio/game_engine/storage/dialog/FileSelectionDialogModule.java new file mode 100644 index 0000000..5916246 --- /dev/null +++ b/src/main/java/de/extio/game_engine/storage/dialog/FileSelectionDialogModule.java @@ -0,0 +1,848 @@ +package de.extio.game_engine.storage.dialog; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +import de.extio.game_engine.event.Event; +import de.extio.game_engine.event.EventService; +import de.extio.game_engine.i18n.LocalizationService; +import de.extio.game_engine.module.AbstractClientModule; +import de.extio.game_engine.renderer.RendererControl; +import de.extio.game_engine.renderer.container.ScrollArea; +import de.extio.game_engine.renderer.container.Window; +import de.extio.game_engine.renderer.model.RenderingBoLayer; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ButtonControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.LabelData; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.TextfieldControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.TextfieldData; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ToggleButtonControl; +import de.extio.game_engine.renderer.model.bo.ControlRenderingBo.ToggleButtonData; +import de.extio.game_engine.renderer.model.bo.DrawFontRenderingBo; +import de.extio.game_engine.renderer.model.bo.HorizontalAlignment; +import de.extio.game_engine.renderer.model.event.UiControlEvent; +import de.extio.game_engine.renderer.work.RenderingBoPool; +import de.extio.game_engine.spatial2.model.Area2; +import de.extio.game_engine.spatial2.model.ImmutableCoordI2; +import de.extio.game_engine.storage.StorageItemDescriptor; +import de.extio.game_engine.storage.StorageResource; +import de.extio.game_engine.storage.StorageService; + +@Component +public class FileSelectionDialogModule extends AbstractClientModule { + + public enum FileSelectionMode { + LOAD, + SAVE, + MANAGE + } + + public record FileSelectionDialogResponse( + String requestId, + boolean confirmed, + FileSelectionMode mode, + StorageResource resource, + UUID id, + String name, + List path, + boolean existing) implements Event { + } + + private static final int WINDOW_WIDTH = 1100; + + private static final int WINDOW_HEIGHT = 760; + + private static final int TITLE_HEIGHT = 46; + + private static final int LABEL_HEIGHT = 26; + + private static final int FIELD_HEIGHT = 36; + + private static final int BUTTON_HEIGHT = 42; + + private static final int BUTTON_WIDTH = 150; + + private static final int SPACING = 12; + + private static final int COLUMN_COUNT = 3; + + private static final String FOLDER_PREFIX = "FileSelectionDialog_Folder_"; + + private static final String ITEM_PREFIX = "FileSelectionDialog_Item_"; + + private static final String NAME_FIELD_ID = "FileSelectionDialog_NameField"; + + private static final String NEW_FOLDER_FIELD_ID = "FileSelectionDialog_NewFolderField"; + + private static final String BUTTON_NEW_FOLDER = "FileSelectionDialog_Button_NewFolder"; + + private static final String BUTTON_NEW_ITEM = "FileSelectionDialog_Button_NewItem"; + + private static final String BUTTON_RENAME = "FileSelectionDialog_Button_Rename"; + + private static final String BUTTON_DELETE = "FileSelectionDialog_Button_Delete"; + + private static final String BUTTON_OK = "FileSelectionDialog_Button_Ok"; + + private static final String BUTTON_CANCEL = "FileSelectionDialog_Button_Cancel"; + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private EventService eventService; + + @Autowired + private LocalizationService localizationService; + + @Autowired + private RenderingBoPool renderingBoPool; + + @Autowired + private StorageService storageService; + + private Window dialogWindow; + + private ScrollArea itemsScrollArea; + + private String requestId; + + private FileSelectionMode mode; + + private List basePath = List.of(); + + private List currentPath = List.of(); + + private String filterPattern; + + private boolean recursive; + + private boolean modal; + + private StorageItemDescriptor selectedDescriptor; + + private boolean selectionIsNew; + + private String nameFieldValue; + + private String newFolderValue; + + private final Map> folderPathByControlId = new HashMap<>(); + + private final Map itemByControlId = new HashMap<>(); + + private final Set> virtualFolders = new HashSet<>(); + + @Override + public boolean isProtected() { + return true; + } + + @Override + public void onLoad() { + this.dialogWindow = this.applicationContext.getBean(Window.class); + this.dialogWindow.setNormalizedDimension(ImmutableCoordI2.create(WINDOW_WIDTH, WINDOW_HEIGHT)); + this.dialogWindow.setNormalizedPosition(this.centeredPosition(this.dialogWindow.getNormalizedDimension())); + this.dialogWindow.setDraggable(true); + this.dialogWindow.setCloseButton(true); + this.dialogWindow.setOnCloseAction(this::onCancel); + + this.itemsScrollArea = this.applicationContext.getBean(ScrollArea.class); + this.dialogWindow.addComponent(this.itemsScrollArea); + + this.getModuleService().changeActiveState(this.getId(), true); + } + + @Override + public void onUnload() { + this.getModuleService().unloadModule(this.dialogWindow.getId()); + } + + @Override + public void onActivate() { + } + + @Override + public void onDeactivate() { + this.eventService.unregisterAll(this.getId()); + } + + @Override + public void onShow() { + this.eventService.register(UiControlEvent.class, this.getId(), this::onUiControlEvent); + this.getModuleService().changeDisplayState(this.dialogWindow.getId(), true); + if (this.modal) { + this.getModuleService().hideExcept(this.getId(), this.dialogWindow.getId()); + } + } + + @Override + public void onHide() { + this.eventService.unregister(UiControlEvent.class, this.getId()); + this.getModuleService().changeDisplayState(this.dialogWindow.getId(), false); + if (this.modal) { + this.getModuleService().restoreVisibility(); + } + this.resetState(); + } + + public void open(final String requestId, final FileSelectionMode mode, final List basePath, final String filterPattern, final boolean recursive, final boolean modal, final String initialName, final Window parentWindow) { + this.requestId = requestId; + this.mode = mode != null ? mode : FileSelectionMode.LOAD; + this.basePath = basePath != null ? List.copyOf(basePath) : List.of(); + this.currentPath = this.basePath; + this.filterPattern = filterPattern; + this.recursive = recursive; + this.modal = modal; + this.selectedDescriptor = null; + this.selectionIsNew = this.mode == FileSelectionMode.SAVE; + this.nameFieldValue = initialName != null && !initialName.isBlank() ? initialName.trim() : ""; + this.newFolderValue = ""; + this.virtualFolders.clear(); + this.dialogWindow.setParent(parentWindow); + this.applyInitialSelection(); + this.buildDialog(); + this.getModuleService().changeDisplayState(this.getId(), true); + } + + private void onUiControlEvent(final UiControlEvent event) { + if (event.getId() == null) { + return; + } + if (event.getId().startsWith(FOLDER_PREFIX)) { + final var targetPath = this.folderPathByControlId.get(event.getId()); + if (targetPath != null) { + this.currentPath = targetPath; + this.selectedDescriptor = null; + this.selectionIsNew = false; + this.nameFieldValue = ""; + this.buildDialog(); + } + return; + } + if (event.getId().startsWith(ITEM_PREFIX)) { + final var descriptor = this.itemByControlId.get(event.getId()); + if (descriptor != null) { + this.selectedDescriptor = descriptor; + this.selectionIsNew = false; + this.nameFieldValue = descriptor.name(); + this.buildDialog(); + } + return; + } + switch (event.getId()) { + case NAME_FIELD_ID -> { + if (event.getPayload() instanceof final String text) { + this.handleNameChanged(text); + } + } + case NEW_FOLDER_FIELD_ID -> { + if (event.getPayload() instanceof final String text) { + this.newFolderValue = text; + } + } + case BUTTON_NEW_FOLDER -> this.createFolder(); + case BUTTON_NEW_ITEM -> this.selectNewItem(); + case BUTTON_RENAME -> this.renameSelection(); + case BUTTON_DELETE -> this.deleteSelection(); + case BUTTON_OK -> this.onOk(); + case BUTTON_CANCEL -> this.onCancel(); + default -> { + } + } + } + + private void buildDialog() { + this.dialogWindow.clearRenderingBos(); + this.clearScrollArea(); + this.folderPathByControlId.clear(); + this.itemByControlId.clear(); + + final var contentWidth = WINDOW_WIDTH - Window.MARGIN_LEFT - Window.MARGIN_RIGHT; + int yOffset = Window.MARGIN_TOP; + + final var title = this.localizationService.translate("ecyoa-185") + " - " + this.getModeLabel(); + final var titleBo = this.renderingBoPool.acquire("FileSelectionDialog_Title", DrawFontRenderingBo.class) + .setText(title) + .setSize(28) + .setAlignment(HorizontalAlignment.CENTER) + .withDimensionAbsolute(contentWidth, TITLE_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT, yOffset); + this.dialogWindow.putRenderingBo(titleBo); + yOffset += TITLE_HEIGHT + SPACING; + + final var pathLabel = this.renderingBoPool.acquire("FileSelectionDialog_Label_Path", ControlRenderingBo.class) + .setType(LabelControl.class) + .setCaption(this.localizationService.translate("ecyoa-188") + ": " + this.formatPath(this.currentPath)) + .setFontSize(18) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(contentWidth, LABEL_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT, yOffset) + .setLayer(RenderingBoLayer.UI0); + this.dialogWindow.putRenderingBo(pathLabel); + yOffset += LABEL_HEIGHT + SPACING / 2; + + if (this.filterPattern != null && !this.filterPattern.isBlank()) { + final var filterLabel = this.renderingBoPool.acquire("FileSelectionDialog_Label_Filter", ControlRenderingBo.class) + .setType(LabelControl.class) + .setCaption(this.localizationService.translate("ecyoa-189") + ": " + this.filterPattern) + .setFontSize(16) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(contentWidth, LABEL_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT, yOffset) + .setLayer(RenderingBoLayer.UI0); + this.dialogWindow.putRenderingBo(filterLabel); + yOffset += LABEL_HEIGHT + SPACING / 2; + } + + final var nameLabel = this.renderingBoPool.acquire("FileSelectionDialog_Label_Name", ControlRenderingBo.class) + .setType(LabelControl.class) + .setCaption(this.localizationService.translate("ecyoa-48") + ":") + .setFontSize(18) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(contentWidth, LABEL_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT, yOffset) + .setLayer(RenderingBoLayer.UI0); + this.dialogWindow.putRenderingBo(nameLabel); + yOffset += LABEL_HEIGHT + SPACING / 2; + + final var showNewItemButton = this.mode != FileSelectionMode.LOAD; + final var nameFieldWidth = showNewItemButton ? contentWidth - BUTTON_WIDTH - SPACING : contentWidth; + final var nameField = this.renderingBoPool.acquire(NAME_FIELD_ID, ControlRenderingBo.class) + .setType(TextfieldControl.class) + .setCaption(this.nameFieldValue != null ? this.nameFieldValue : "") + .setControlData(new TextfieldData(false, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(nameFieldWidth, FIELD_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT, yOffset) + .setLayer(RenderingBoLayer.UI1); + this.dialogWindow.putRenderingBo(nameField); + if (showNewItemButton) { + final var newItemButton = this.renderingBoPool.acquire(BUTTON_NEW_ITEM, ControlRenderingBo.class) + .setType(ButtonControl.class) + .setCaption(this.localizationService.translate("ecyoa-191")) + .setVisible(true) + .setEnabled(this.canSelectNewItem()) + .withDimensionAbsolute(BUTTON_WIDTH, FIELD_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT + nameFieldWidth + SPACING, yOffset) + .setLayer(RenderingBoLayer.UI1); + this.dialogWindow.putRenderingBo(newItemButton); + } + yOffset += FIELD_HEIGHT + SPACING; + + final boolean showFolderField = this.recursive && this.mode != FileSelectionMode.LOAD; + if (showFolderField) { + final var folderLabel = this.renderingBoPool.acquire("FileSelectionDialog_Label_NewFolder", ControlRenderingBo.class) + .setType(LabelControl.class) + .setCaption(this.localizationService.translate("ecyoa-190") + ":") + .setFontSize(18) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(contentWidth, LABEL_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT, yOffset) + .setLayer(RenderingBoLayer.UI0); + this.dialogWindow.putRenderingBo(folderLabel); + yOffset += LABEL_HEIGHT + SPACING / 2; + + final var folderField = this.renderingBoPool.acquire(NEW_FOLDER_FIELD_ID, ControlRenderingBo.class) + .setType(TextfieldControl.class) + .setCaption(this.newFolderValue != null ? this.newFolderValue : "") + .setControlData(new TextfieldData(false, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(contentWidth, FIELD_HEIGHT) + .withPositionRelative(Window.MARGIN_LEFT, yOffset) + .setLayer(RenderingBoLayer.UI1); + this.dialogWindow.putRenderingBo(folderField); + yOffset += FIELD_HEIGHT + SPACING; + } + + final var bottomRowY = WINDOW_HEIGHT - Window.MARGIN_BOTTOM - BUTTON_HEIGHT; + final var scrollHeight = Math.max(120, bottomRowY - yOffset - SPACING); + this.itemsScrollArea.setRelativeArea(new Area2(ImmutableCoordI2.create(Window.MARGIN_LEFT, yOffset), ImmutableCoordI2.create(contentWidth, scrollHeight))); + this.buildItemsList(contentWidth); + + var buttonX = Window.MARGIN_LEFT; + if (this.mode != FileSelectionMode.LOAD && this.recursive) { + final var newFolderButton = this.renderingBoPool.acquire(BUTTON_NEW_FOLDER, ControlRenderingBo.class) + .setType(ButtonControl.class) + .setCaption(this.localizationService.translate("ecyoa-190")) + .setVisible(true) + .setEnabled(this.canCreateFolder()) + .withDimensionAbsolute(BUTTON_WIDTH + 20, BUTTON_HEIGHT) + .withPositionRelative(buttonX, bottomRowY) + .setLayer(RenderingBoLayer.UI1); + this.dialogWindow.putRenderingBo(newFolderButton); + buttonX += BUTTON_WIDTH + 20 + SPACING; + } + final var renameButton = this.renderingBoPool.acquire(BUTTON_RENAME, ControlRenderingBo.class) + .setType(ButtonControl.class) + .setCaption(this.localizationService.translate("ecyoa-57")) + .setVisible(true) + .setEnabled(this.canRename()) + .withDimensionAbsolute(BUTTON_WIDTH, BUTTON_HEIGHT) + .withPositionRelative(buttonX, bottomRowY) + .setLayer(RenderingBoLayer.UI1); + this.dialogWindow.putRenderingBo(renameButton); + buttonX += BUTTON_WIDTH + SPACING; + + final var deleteButton = this.renderingBoPool.acquire(BUTTON_DELETE, ControlRenderingBo.class) + .setType(ButtonControl.class) + .setCaption(this.localizationService.translate("ecyoa-68")) + .setVisible(true) + .setEnabled(this.selectedDescriptor != null) + .withDimensionAbsolute(BUTTON_WIDTH, BUTTON_HEIGHT) + .withPositionRelative(buttonX, bottomRowY) + .setLayer(RenderingBoLayer.UI1); + this.dialogWindow.putRenderingBo(deleteButton); + buttonX += BUTTON_WIDTH + SPACING; + + final var okButton = this.renderingBoPool.acquire(BUTTON_OK, ControlRenderingBo.class) + .setType(ButtonControl.class) + .setCaption(this.localizationService.translate("ecyoa-3")) + .setVisible(true) + .setEnabled(this.canConfirm()) + .withDimensionAbsolute(BUTTON_WIDTH, BUTTON_HEIGHT) + .withPositionRelative(WINDOW_WIDTH - Window.MARGIN_RIGHT - BUTTON_WIDTH * 2 - SPACING, bottomRowY) + .setLayer(RenderingBoLayer.UI1); + this.dialogWindow.putRenderingBo(okButton); + + final var cancelButton = this.renderingBoPool.acquire(BUTTON_CANCEL, ControlRenderingBo.class) + .setType(ButtonControl.class) + .setCaption(this.localizationService.translate("ecyoa-89")) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(BUTTON_WIDTH, BUTTON_HEIGHT) + .withPositionRelative(WINDOW_WIDTH - Window.MARGIN_RIGHT - BUTTON_WIDTH, bottomRowY) + .setLayer(RenderingBoLayer.UI1); + this.dialogWindow.putRenderingBo(cancelButton); + + this.itemsScrollArea.draw(); + this.dialogWindow.draw(); + } + + private void buildItemsList(final int contentWidth) { + final var descriptors = this.loadDescriptors(); + final var currentItems = new ArrayList(); + final var folderNames = new HashSet(); + for (final var descriptor : descriptors) { + if (descriptor == null) { + continue; + } + final var path = this.safePath(descriptor.path()); + if (this.isPathPrefix(this.currentPath, path)) { + if (path.size() == this.currentPath.size()) { + currentItems.add(descriptor); + } + else if (this.recursive) { + folderNames.add(path.get(this.currentPath.size())); + } + } + } + if (this.recursive) { + for (final var virtualPath : this.virtualFolders) { + if (this.isPathPrefix(this.currentPath, virtualPath) && virtualPath.size() > this.currentPath.size()) { + folderNames.add(virtualPath.get(this.currentPath.size())); + } + } + } + + final var sortedFolders = folderNames.stream().sorted(String::compareToIgnoreCase).toList(); + currentItems.sort(Comparator.comparing(StorageItemDescriptor::name, String.CASE_INSENSITIVE_ORDER)); + + final var entries = new ArrayList(); + if (this.recursive && this.currentPath.size() > this.basePath.size()) { + final var upPath = List.copyOf(this.currentPath.subList(0, this.currentPath.size() - 1)); + entries.add(DisplayEntry.folder("..", upPath)); + } + for (final var folderName : sortedFolders) { + entries.add(DisplayEntry.folder(folderName + "/", this.appendPath(this.currentPath, folderName))); + } + for (final var item : currentItems) { + entries.add(DisplayEntry.item(item)); + } + + final var leftPad = Window.MARGIN_LEFT; + final var availableWidth = contentWidth - ScrollArea.SCROLLBAR_WIDTH_WITH_MARGIN - leftPad; + final var columnSpacing = SPACING; + final var columnWidth = Math.max(120, (availableWidth - columnSpacing * (COLUMN_COUNT - 1)) / COLUMN_COUNT); + final var rowSpacing = SPACING / 2; + + int index = 0; + for (final var entry : entries) { + final var col = index % COLUMN_COUNT; + final var row = index / COLUMN_COUNT; + final var x = leftPad + col * (columnWidth + columnSpacing); + final var y = row * (FIELD_HEIGHT + rowSpacing); + if (entry.isFolder()) { + final var controlId = FOLDER_PREFIX + index + "_" + entry.caption().hashCode(); + this.folderPathByControlId.put(controlId, entry.path()); + final var folderButton = this.renderingBoPool.acquire(controlId, ControlRenderingBo.class) + .setType(ButtonControl.class) + .setCaption(entry.caption()) + .setTooltip(this.formatPath(entry.path())) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(columnWidth, FIELD_HEIGHT) + .withPositionRelative(x, y) + .setLayer(RenderingBoLayer.UI1); + this.itemsScrollArea.putRenderingBo(folderButton); + } + else if (entry.item() != null) { + final var item = entry.item(); + final var controlId = ITEM_PREFIX + item.id(); + this.itemByControlId.put(controlId, item); + final var selected = this.selectedDescriptor != null && Objects.equals(this.selectedDescriptor.id(), item.id()); + final var resourcePath = this.formatPath(this.safePath(item.path())) + "/" + item.name(); + final var itemButton = this.renderingBoPool.acquire(controlId, ControlRenderingBo.class) + .setType(ToggleButtonControl.class) + .setCaption(item.name()) + .setTooltip(resourcePath) + .setControlGroup("FileSelectionDialog_Items") + .setControlData(new ToggleButtonData(selected, true, null)) + .setVisible(true) + .setEnabled(true) + .withDimensionAbsolute(columnWidth, FIELD_HEIGHT) + .withPositionRelative(x, y) + .setLayer(RenderingBoLayer.UI1); + this.itemsScrollArea.putRenderingBo(itemButton); + } + index++; + } + if (entries.isEmpty()) { + final var emptyLabel = this.renderingBoPool.acquire("FileSelectionDialog_Label_Empty", ControlRenderingBo.class) + .setType(LabelControl.class) + .setCaption(this.localizationService.translate("ecyoa-193")) + .setControlData(new LabelData(null, null, HorizontalAlignment.CENTER)) + .setFontSize(18) + .setVisible(true) + .setEnabled(false) + .withDimensionAbsolute(columnWidth, FIELD_HEIGHT) + .withPositionRelative(leftPad, 10) + .setLayer(RenderingBoLayer.UI0); + this.itemsScrollArea.putRenderingBo(emptyLabel); + } + } + + private List loadDescriptors() { + final var queryPath = this.recursive ? this.basePath : this.currentPath; + if (this.filterPattern != null && !this.filterPattern.isBlank()) { + return this.storageService.searchByPattern(queryPath, this.filterPattern, this.recursive); + } + return this.storageService.listPath(queryPath, this.recursive); + } + + private void createFolder() { + if (!this.canCreateFolder()) { + return; + } + final var newPath = this.appendPath(this.currentPath, this.newFolderValue.trim()); + this.virtualFolders.add(newPath); + this.currentPath = newPath; + this.newFolderValue = ""; + this.selectedDescriptor = null; + this.selectionIsNew = false; + this.nameFieldValue = ""; + this.buildDialog(); + } + + private void selectNewItem() { + if (!this.canSelectNewItem()) { + return; + } + this.selectedDescriptor = null; + this.selectionIsNew = true; + this.nameFieldValue = this.ensureFreeNameForNewItem(this.nameFieldValue); + this.buildDialog(); + } + + private void renameSelection() { + if (!this.canRename()) { + return; + } + final var newName = this.nameFieldValue.trim(); + this.storageService.moveById(this.selectedDescriptor.id(), this.selectedDescriptor.path(), newName); + this.nameFieldValue = newName; + this.selectionIsNew = false; + this.selectedDescriptor = null; + this.buildDialog(); + } + + private void deleteSelection() { + if (this.selectedDescriptor == null) { + return; + } + this.storageService.deleteById(this.selectedDescriptor.id()); + this.selectedDescriptor = null; + this.selectionIsNew = false; + this.nameFieldValue = ""; + this.buildDialog(); + } + + private void onOk() { + if (!this.canConfirm()) { + return; + } + final var name = this.resolveSelectionName(); + final var path = this.resolveSelectionPath(); + final var existing = this.selectedDescriptor != null && name != null && name.equals(this.selectedDescriptor.name()); + final var id = existing ? this.selectedDescriptor.id() : null; + final var resource = name != null ? new StorageResource(path, name) : null; + this.eventService.fire(new FileSelectionDialogResponse(this.requestId, true, this.mode, resource, id, name, path, existing)); + this.close(); + } + + private void onCancel() { + this.eventService.fire(new FileSelectionDialogResponse(this.requestId, false, this.mode, null, null, null, null, false)); + this.close(); + } + + private void close() { + this.getModuleService().changeDisplayState(this.getId(), false); + } + + private void resetState() { + this.requestId = null; + this.mode = FileSelectionMode.LOAD; + this.basePath = List.of(); + this.currentPath = List.of(); + this.filterPattern = null; + this.recursive = false; + this.modal = false; + this.selectedDescriptor = null; + this.selectionIsNew = false; + this.nameFieldValue = ""; + this.newFolderValue = ""; + this.virtualFolders.clear(); + this.folderPathByControlId.clear(); + this.itemByControlId.clear(); + this.clearScrollArea(); + } + + private void clearScrollArea() { + for (final var boId : this.itemsScrollArea.getRenderingBoIds().toArray(new String[0])) { + this.itemsScrollArea.removeRenderingBo(boId); + } + } + + private boolean canCreateFolder() { + if (!this.recursive || this.mode == FileSelectionMode.LOAD) { + return false; + } + if (this.newFolderValue == null || this.newFolderValue.isBlank()) { + return false; + } + final var name = this.newFolderValue.trim(); + final var targetPath = this.appendPath(this.currentPath, name); + return !this.virtualFolders.contains(targetPath) && !this.folderExistsInStorage(name); + } + + private boolean folderExistsInStorage(final String name) { + final var descriptors = this.loadDescriptors(); + for (final var descriptor : descriptors) { + if (descriptor == null) { + continue; + } + final var path = this.safePath(descriptor.path()); + if (this.isPathPrefix(this.currentPath, path) && path.size() > this.currentPath.size()) { + if (name.equals(path.get(this.currentPath.size()))) { + return true; + } + } + } + return false; + } + + private boolean canSelectNewItem() { + if (this.mode == FileSelectionMode.LOAD) { + return false; + } + return this.nameFieldValue != null && !this.nameFieldValue.isBlank(); + } + + private boolean canRename() { + if (this.selectedDescriptor == null || this.nameFieldValue == null || this.nameFieldValue.isBlank()) { + return false; + } + final var trimmed = this.nameFieldValue.trim(); + if (trimmed.equalsIgnoreCase(this.selectedDescriptor.name())) { + return false; + } + return this.findDescriptorByName(trimmed) == null; + } + + private boolean canConfirm() { + return switch (this.mode) { + case LOAD -> this.selectedDescriptor != null; + case SAVE -> this.resolveSelectionName() != null; + case MANAGE -> true; + }; + } + + private String resolveSelectionName() { + final var name = this.nameFieldValue != null ? this.nameFieldValue.trim() : ""; + if (!name.isBlank()) { + return name; + } + if (this.selectedDescriptor != null) { + return this.selectedDescriptor.name(); + } + return null; + } + + private void handleNameChanged(final String text) { + if (Objects.equals(this.nameFieldValue, text)) { + return; + } + this.nameFieldValue = text; + final var trimmed = text != null ? text.trim() : ""; + if (this.selectedDescriptor == null && !trimmed.isBlank()) { + final var match = this.findDescriptorByName(trimmed); + if (match != null) { + this.selectedDescriptor = match; + this.selectionIsNew = false; + this.buildDialog(); + return; + } + } + + if (this.selectedDescriptor == null) { + this.selectionIsNew = true; + } + this.buildDialog(); + } + + private void applyInitialSelection() { + if (this.nameFieldValue == null || this.nameFieldValue.isBlank()) { + this.selectionIsNew = false; + this.selectedDescriptor = null; + return; + } + final var match = this.findDescriptorByName(this.nameFieldValue); + if (this.mode != FileSelectionMode.SAVE && match != null) { + this.selectedDescriptor = match; + this.selectionIsNew = false; + this.nameFieldValue = match.name(); + return; + } + if (this.selectionIsNew || this.mode == FileSelectionMode.SAVE) { + this.nameFieldValue = this.ensureFreeNameForNewItem(this.nameFieldValue); + } + } + + private String ensureFreeNameForNewItem(final String baseName) { + if (baseName == null || baseName.isBlank()) { + return baseName; + } + if (this.findDescriptorByName(baseName) == null) { + return baseName; + } + int index = 2; + String candidate; + do { + candidate = baseName + " " + index++; + } while (this.findDescriptorByName(candidate) != null); + return candidate; + } + + private StorageItemDescriptor findDescriptorByName(final String name) { + if (name == null || name.isBlank()) { + return null; + } + final var descriptors = this.loadDescriptors(); + for (final var descriptor : descriptors) { + if (descriptor == null) { + continue; + } + final var path = this.safePath(descriptor.path()); + if (path.size() == this.currentPath.size() && this.isPathPrefix(this.currentPath, path)) { + if (name.equalsIgnoreCase(descriptor.name())) { + return descriptor; + } + } + } + return null; + } + + private record DisplayEntry(String caption, List path, StorageItemDescriptor item) { + private static DisplayEntry folder(final String caption, final List path) { + return new DisplayEntry(caption, path, null); + } + + private static DisplayEntry item(final StorageItemDescriptor item) { + return new DisplayEntry(item.name(), null, item); + } + + private boolean isFolder() { + return this.path != null; + } + } + + private List resolveSelectionPath() { + if (this.selectedDescriptor != null) { + return this.safePath(this.selectedDescriptor.path()); + } + return this.currentPath; + } + + private String formatPath(final List path) { + if (path == null || path.isEmpty()) { + return "/"; + } + return "/" + String.join("/", path); + } + + private List safePath(final List path) { + return path != null ? List.copyOf(path) : List.of(); + } + + private boolean isPathPrefix(final List prefix, final List candidate) { + if (prefix.size() > candidate.size()) { + return false; + } + for (int i = 0; i < prefix.size(); i++) { + if (!Objects.equals(prefix.get(i), candidate.get(i))) { + return false; + } + } + return true; + } + + private List appendPath(final List base, final String segment) { + final var result = new ArrayList(base.size() + 1); + result.addAll(base); + result.add(segment); + return List.copyOf(result); + } + + private String getModeLabel() { + return switch (this.mode) { + case LOAD -> this.localizationService.translate("ecyoa-186"); + case SAVE -> this.localizationService.translate("ecyoa-66"); + case MANAGE -> this.localizationService.translate("ecyoa-187"); + }; + } + + private ImmutableCoordI2 centeredPosition(final de.extio.game_engine.spatial2.model.CoordI2 dimension) { + final var referenceResolution = RendererControl.REFERENCE_RESOLUTION; + final var x = (referenceResolution.getX() - dimension.getX()) / 2; + final var y = (referenceResolution.getY() - dimension.getY()) / 2; + return ImmutableCoordI2.create(x, y); + } +}