Browser quick access improvements

This commit is contained in:
crschnick 2024-03-29 18:32:00 +00:00
parent 061dbe1cf3
commit 9ad5b6f7f5
7 changed files with 386 additions and 244 deletions

View file

@ -9,6 +9,7 @@ import io.xpipe.app.fxcomps.SimpleComp;
import io.xpipe.app.fxcomps.SimpleCompStructure;
import io.xpipe.app.fxcomps.augment.ContextMenuAugment;
import io.xpipe.app.fxcomps.impl.PrettyImageHelper;
import io.xpipe.app.fxcomps.util.BindingsHelper;
import io.xpipe.app.fxcomps.util.PlatformThread;
import io.xpipe.app.util.BooleanScope;
import io.xpipe.app.util.HumanReadableFormat;
@ -503,20 +504,16 @@ final class BrowserFileListComp extends SimpleComp {
.get();
var quickAccess = new BrowserQuickAccessButtonComp(
() -> getTableRow().getItem(), fileList.getFileSystemModel())
.hide(Bindings.createBooleanBinding(
.hide(BindingsHelper.persist(Bindings.createBooleanBinding(
() -> {
var notDir = getTableRow()
.getItem()
.getRawFileEntry()
.getKind()
!= FileKind.DIRECTORY;
var isParentLink = getTableRow()
.getItem()
var item = getTableRow().getItem();
var notDir = item.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY;
var isParentLink = item
.getRawFileEntry()
.equals(fileList.getFileSystemModel().getCurrentParentDirectory());
return notDir || isParentLink;
},
itemProperty()))
itemProperty())))
.createRegion();
editing.addListener((observable, oldValue, newValue) -> {

View file

@ -87,20 +87,20 @@ public final class BrowserFileListModel {
: all.getValue();
var listCopy = new ArrayList<>(filtered);
sort(listCopy);
listCopy.sort(order());
shown.setValue(listCopy);
}
private void sort(List<BrowserEntry> l) {
public Comparator<BrowserEntry> order() {
var syntheticFirst = Comparator.<BrowserEntry, Boolean>comparing(path -> !path.isSynthetic());
var dirsFirst = Comparator.<BrowserEntry, Boolean>comparing(
path -> path.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY);
var comp = comparatorProperty.getValue();
Comparator<? super BrowserEntry> us = comp != null
Comparator<BrowserEntry> us = comp != null
? syntheticFirst.thenComparing(dirsFirst).thenComparing(comp)
: syntheticFirst.thenComparing(dirsFirst);
l.sort(us);
return us;
}
public boolean rename(String filename, String newName) {

View file

@ -1,28 +1,12 @@
package io.xpipe.app.browser;
import io.xpipe.app.browser.icon.FileIconManager;
import io.xpipe.app.fxcomps.SimpleComp;
import io.xpipe.app.fxcomps.impl.IconButtonComp;
import io.xpipe.app.fxcomps.impl.PrettyImageHelper;
import io.xpipe.app.util.BooleanAnimationTimer;
import io.xpipe.app.util.ThreadHelper;
import io.xpipe.core.store.FileKind;
import io.xpipe.core.store.FileSystem;
import javafx.application.Platform;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.Side;
import javafx.scene.Node;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Menu;
import javafx.scene.control.MenuItem;
import javafx.scene.layout.Region;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import java.util.stream.Collectors;
public class BrowserQuickAccessButtonComp extends SimpleComp {
@ -36,188 +20,28 @@ public class BrowserQuickAccessButtonComp extends SimpleComp {
@Override
protected Region createSimple() {
var cm = new ContextMenu();
var cm = new BrowserQuickAccessContextMenu(base, model);
var button = new IconButtonComp("mdi2c-chevron-double-right");
button.apply(struc -> {
struc.get().setOnAction(event -> {
if (!cm.isShowing()) {
showMenu(cm, struc.get());
cm.showMenu(struc.get());
} else {
cm.hide();
}
event.consume();
});
cm.addEventFilter(Menu.ON_HIDDEN, e -> {
Platform.runLater(() -> {
struc.get().requestFocus();
});
});
BrowserQuickAccessContextMenu.onRight(struc.get(), false, keyEvent -> {
cm.showMenu(struc.get());
keyEvent.consume();
});
});
button.styleClass("quick-access-button");
return button.createRegion();
}
private void showMenu(ContextMenu cm, Node anchor) {
cm.getItems().clear();
cm.addEventHandler(Menu.ON_SHOWING, e -> {
Node content = cm.getSkin().getNode();
if (content instanceof Region r) {
r.setMaxWidth(500);
}
});
cm.setAutoHide(true);
cm.getStyleClass().add("condensed");
ThreadHelper.runFailableAsync(() -> {
var fileEntry = base.get().getRawFileEntry();
if (fileEntry.getKind() != FileKind.DIRECTORY) {
return;
}
var actionsMenu = new AtomicReference<ContextMenu>();
var r = new Menu();
var newItems = updateMenuItems(cm, r, fileEntry, true, actionsMenu);
Platform.runLater(() -> {
cm.getItems().addAll(r.getItems());
cm.show(anchor, Side.RIGHT, 0, 0);
});
});
}
private MenuItem createItem(
ContextMenu contextMenu, FileSystem.FileEntry fileEntry, AtomicReference<ContextMenu> showingActionsMenu) {
var browserCm = new BrowserContextMenu(model, new BrowserEntry(fileEntry, model.getFileList(), false));
browserCm.setOnAction(e -> {
contextMenu.hide();
});
if (fileEntry.getKind() != FileKind.DIRECTORY) {
var m = new Menu(
fileEntry.getName(),
PrettyImageHelper.ofFixedSizeSquare(FileIconManager.getFileIcon(fileEntry, false), 24)
.createRegion());
m.setMnemonicParsing(false);
m.setOnAction(event -> {
if (event.getTarget() != m) {
return;
}
browserCm.show(m.getStyleableNode(), Side.RIGHT, 0, 0);
showingActionsMenu.set(browserCm);
});
m.getStyleClass().add("leaf");
return m;
}
var m = new Menu(
fileEntry.getName(),
PrettyImageHelper.ofFixedSizeSquare(FileIconManager.getFileIcon(fileEntry, false), 24)
.createRegion());
m.setMnemonicParsing(false);
var empty = new MenuItem("...");
m.getItems().add(empty);
var hover = new SimpleBooleanProperty();
m.setOnShowing(event -> {
var actionsMenu = showingActionsMenu.get();
if (actionsMenu != null) {
actionsMenu.hide();
showingActionsMenu.set(null);
}
hover.set(true);
event.consume();
});
m.setOnHiding(event -> {
var actionsMenu = showingActionsMenu.get();
if (actionsMenu != null) {
actionsMenu.hide();
showingActionsMenu.set(null);
}
hover.set(false);
event.consume();
});
new BooleanAnimationTimer(hover, 100, () -> {
if (m.isShowing() && !m.getItems().getFirst().equals(empty)) {
return;
}
ThreadHelper.runFailableAsync(() -> {
var newItems = updateMenuItems(contextMenu, m, fileEntry, false, showingActionsMenu);
Platform.runLater(() -> {
m.getItems().setAll(newItems);
if (!browserCm.isShowing() && m.isShowing()) {
m.hide();
m.show();
}
});
});
})
.start();
m.setOnAction(event -> {
if (event.getTarget() != m) {
return;
}
var actionsMenu = showingActionsMenu.get();
if (actionsMenu != null && actionsMenu.isShowing()) {
actionsMenu.hide();
showingActionsMenu.set(null);
m.show();
return;
}
m.hide();
browserCm.show(m.getStyleableNode(), Side.RIGHT, 0, 0);
showingActionsMenu.set(browserCm);
event.consume();
});
return m;
}
private List<MenuItem> updateMenuItems(
ContextMenu contextMenu,
Menu m,
FileSystem.FileEntry fileEntry,
boolean updateInstantly,
AtomicReference<ContextMenu> showingActionsMenu)
throws Exception {
var newFiles = model.getFileSystem().listFiles(fileEntry.getPath());
try (var s = newFiles) {
var list = s.toList();
// Wait until all files are listed, i.e. do not skip the stream elements
list = list.subList(0, Math.min(list.size(), 150));
var newItems = new ArrayList<MenuItem>();
if (list.isEmpty()) {
newItems.add(new MenuItem("<empty>"));
} else {
var menus = list.stream()
.sorted((o1, o2) -> {
if (o1.getKind() == FileKind.DIRECTORY && o2.getKind() != FileKind.DIRECTORY) {
return -1;
}
if (o2.getKind() == FileKind.DIRECTORY && o1.getKind() != FileKind.DIRECTORY) {
return 1;
}
return o1.getName().compareToIgnoreCase(o2.getName());
})
.collect(Collectors.toMap(
e -> e,
e -> createItem(contextMenu, e, showingActionsMenu),
(v1, v2) -> v2,
LinkedHashMap::new));
var dirs = list.stream()
.filter(e -> e.getKind() == FileKind.DIRECTORY)
.toList();
if (dirs.size() == 1) {
updateMenuItems(
contextMenu,
(Menu) menus.get(dirs.getFirst()),
dirs.getFirst(),
true,
showingActionsMenu);
}
newItems.addAll(menus.values());
}
if (updateInstantly) {
m.getItems().setAll(newItems);
}
return newItems;
}
}
}

View file

@ -0,0 +1,348 @@
package io.xpipe.app.browser;
import io.xpipe.app.browser.icon.FileIconManager;
import io.xpipe.app.fxcomps.impl.PrettyImageHelper;
import io.xpipe.app.util.BooleanAnimationTimer;
import io.xpipe.app.util.ThreadHelper;
import io.xpipe.core.store.FileKind;
import javafx.application.Platform;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.event.EventHandler;
import javafx.event.EventTarget;
import javafx.geometry.Side;
import javafx.scene.Node;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Menu;
import javafx.scene.control.MenuItem;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Region;
import lombok.Getter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.Collectors;
public class BrowserQuickAccessContextMenu extends ContextMenu {
static void onLeft(EventTarget target, boolean filter, Consumer<KeyEvent> r) {
EventHandler<KeyEvent> keyEventEventHandler = event -> {
if (event.getCode() == KeyCode.LEFT || event.getCode() == KeyCode.NUMPAD4) {
r.accept(event);
}
};
if (filter) {
target.addEventFilter(KeyEvent.KEY_PRESSED, keyEventEventHandler);
} else {
target.addEventHandler(KeyEvent.KEY_PRESSED, keyEventEventHandler);
}
}
static void onRight(EventTarget target, boolean filter, Consumer<KeyEvent> r) {
EventHandler<KeyEvent> keyEventEventHandler = event -> {
if (event.getCode() == KeyCode.RIGHT || event.getCode() == KeyCode.NUMPAD6) {
r.accept(event);
}
};
if (filter) {
target.addEventFilter(KeyEvent.KEY_PRESSED, keyEventEventHandler);
} else {
target.addEventHandler(KeyEvent.KEY_PRESSED, keyEventEventHandler);
}
}
@Getter
class QuickAccessMenu {
private final BrowserEntry browserEntry;
private ContextMenu browserActionMenu;
private final Menu menu;
public QuickAccessMenu(BrowserEntry browserEntry) {
this.browserEntry = browserEntry;
this.menu = new Menu(
// Use original name, not the link target
browserEntry.getRawFileEntry().getName(),
PrettyImageHelper.ofFixedSizeSquare(FileIconManager.getFileIcon(browserEntry.getRawFileEntry(), false), 24)
.createRegion());
createMenu();
addInputListeners();
}
private void createMenu() {
var fileEntry = browserEntry.getRawFileEntry();
if (fileEntry.resolved().getKind() != FileKind.DIRECTORY) {
createFileMenu();
} else {
createDirectoryMenu();
}
}
private void createFileMenu() {
var fileEntry = browserEntry.getRawFileEntry();
menu.setMnemonicParsing(false);
menu.addEventFilter(Menu.ON_SHOWN, event -> {
menu.hide();
if (keyBasedNavigation && expandBrowserActionMenuKey) {
if (!hideBrowserActionsMenu()) {
showBrowserActionsMenu();
}
}
});
menu.setOnAction(event -> {
if (event.getTarget() != menu) {
return;
}
if (!hideBrowserActionsMenu()) {
showBrowserActionsMenu();
}
});
menu.getStyleClass().add("leaf");
var empty = new MenuItem("...");
empty.setDisable(true);
menu.getItems().add(empty);
onRight(empty, true, keyEvent -> {
keyEvent.consume();
});
}
private void createDirectoryMenu() {
var fileEntry = browserEntry.getRawFileEntry().resolved();
menu.setMnemonicParsing(false);
var empty = new MenuItem("...");
empty.setDisable(true);
menu.getItems().add(empty);
addHoverHandling(menu, empty);
menu.setOnAction(event -> {
if (event.getTarget() != menu) {
return;
}
if (hideBrowserActionsMenu()) {
menu.show();
event.consume();
return;
}
showBrowserActionsMenu();
event.consume();
});
menu.addEventFilter(Menu.ON_SHOWING, event -> {
hideBrowserActionsMenu();
});
menu.addEventFilter(Menu.ON_SHOWN, event -> {
if (keyBasedNavigation && expandBrowserActionMenuKey) {
if (hideBrowserActionsMenu()) {
menu.show();
} else {
showBrowserActionsMenu();
}
} else if (keyBasedNavigation) {
expandDirectoryMenu(empty);
}
});
menu.addEventFilter(Menu.ON_HIDING, event -> {
if (closeBrowserActionMenuKey) {
menu.show();
}
});
}
private void addHoverHandling(Menu m, MenuItem empty) {
var hover = new SimpleBooleanProperty();
menu.addEventFilter(Menu.ON_SHOWING, event -> {
if (!keyBasedNavigation) {
hover.set(true);
}
});
menu.addEventFilter(Menu.ON_HIDING, event -> {
if (!keyBasedNavigation) {
hover.set(false);
}
});
new BooleanAnimationTimer(hover, 100, () -> {
expandDirectoryMenu(empty);
}).start();
}
private void addInputListeners() {
menu.parentPopupProperty().subscribe(contextMenu -> {
if (contextMenu != null) {
contextMenu.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
keyBasedNavigation = true;
if (event.getCode().equals(KeyCode.ENTER)) {
expandBrowserActionMenuKey = true;
} else {
expandBrowserActionMenuKey = false;
}
if (event.getCode().equals(KeyCode.LEFT) && browserActionMenu != null && browserActionMenu.isShowing()) {
closeBrowserActionMenuKey = true;
} else {
closeBrowserActionMenuKey = false;
}
});
contextMenu.addEventFilter(MouseEvent.ANY,event -> {
keyBasedNavigation = false;
});
}
});
}
private void expandDirectoryMenu(MenuItem empty) {
if (menu.isShowing() && !menu.getItems().getFirst().equals(empty)) {
return;
}
ThreadHelper.runFailableAsync(() -> {
var newItems = updateMenuItems(menu, browserEntry, false);
Platform.runLater(() -> {
var reshow = (browserActionMenu == null || !browserActionMenu.isShowing()) && menu.isShowing();
if (reshow) {
menu.hide();
}
menu.getItems().setAll(newItems);
if (reshow) {
menu.show();
}
});
});
}
private boolean hideBrowserActionsMenu() {
if (shownBrowserActionsMenu != null && shownBrowserActionsMenu.isShowing()) {
shownBrowserActionsMenu.hide();
shownBrowserActionsMenu = null;
return true;
}
return false;
}
private void showBrowserActionsMenu() {
if (browserActionMenu == null) {
this.browserActionMenu = new BrowserContextMenu(model, browserEntry);
this.browserActionMenu.setOnAction(e -> {
hide();
});
onLeft(this.browserActionMenu, true, keyEvent -> {
this.browserActionMenu.hide();
keyEvent.consume();
});
}
menu.hide();
browserActionMenu.show(menu.getStyleableNode(), Side.RIGHT, 0, 0);
shownBrowserActionsMenu = browserActionMenu;
Platform.runLater(() -> {
browserActionMenu.getItems().getFirst().getStyleableNode().requestFocus();
});
}
}
private final Supplier<BrowserEntry> base;
private final OpenFileSystemModel model;
private ContextMenu shownBrowserActionsMenu;
private boolean expandBrowserActionMenuKey;
private boolean keyBasedNavigation;
private boolean closeBrowserActionMenuKey;
public BrowserQuickAccessContextMenu(Supplier<BrowserEntry> base, OpenFileSystemModel model) {
this.base = base;
this.model = model;
addEventFilter(Menu.ON_SHOWING, e -> {
Node content = getSkin().getNode();
if (content instanceof Region r) {
r.setMaxWidth(500);
}
});
addEventFilter(Menu.ON_SHOWN, e -> {
Platform.runLater(() -> {
getItems().getFirst().getStyleableNode().requestFocus();
});
});
onLeft(this, false, e -> {
hide();
e.consume();
});
setAutoHide(true);
getStyleClass().add("condensed");
}
public void showMenu(Node anchor) {
getItems().clear();
ThreadHelper.runFailableAsync(() -> {
var entry = base.get();
if (entry.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY) {
return;
}
var actionsMenu = new AtomicReference<ContextMenu>();
var r = new Menu();
var newItems = updateMenuItems(r, entry, true);
Platform.runLater(() -> {
getItems().addAll(r.getItems());
show(anchor, Side.RIGHT, 0, 0);
});
});
}
private MenuItem createItem(BrowserEntry browserEntry) {
return new QuickAccessMenu(browserEntry).getMenu();
}
private List<MenuItem> updateMenuItems(
Menu m,
BrowserEntry entry,
boolean updateInstantly)
throws Exception {
var newFiles = model.getFileSystem().listFiles(entry.getRawFileEntry().resolved().getPath());
try (var s = newFiles) {
var list = s.map(fileEntry -> fileEntry.resolved()).toList();
// Wait until all files are listed, i.e. do not skip the stream elements
list = list.subList(0, Math.min(list.size(), 150));
var newItems = new ArrayList<MenuItem>();
if (list.isEmpty()) {
var empty = new Menu("<empty>");
empty.getStyleClass().add("leaf");
newItems.add(empty);
} else {
var browserEntries = list.stream()
.map(fileEntry -> new BrowserEntry(fileEntry, model.getFileList(), false))
.toList();
var menus = browserEntries.stream()
.sorted(model.getFileList().order())
.collect(Collectors.toMap(
e -> e,
e -> createItem(e),
(v1, v2) -> v2,
LinkedHashMap::new));
var dirs = browserEntries.stream()
.filter(e -> e.getRawFileEntry().getKind() == FileKind.DIRECTORY)
.toList();
if (dirs.size() == 1) {
updateMenuItems(
(Menu) menus.get(dirs.getFirst()),
dirs.getFirst(),
true);
}
newItems.addAll(menus.values());
}
if (updateInstantly) {
m.getItems().setAll(newItems);
}
return newItems;
}
}
}

View file

@ -9,7 +9,6 @@ import io.xpipe.app.core.AppWindowHelper;
import io.xpipe.app.fxcomps.Comp;
import io.xpipe.app.fxcomps.SimpleComp;
import io.xpipe.app.fxcomps.augment.GrowAugment;
import io.xpipe.app.util.JfxHelper;
import io.xpipe.app.util.LicenseRequiredException;
import io.xpipe.app.util.PlatformState;
import javafx.application.Platform;
@ -151,8 +150,18 @@ public class ErrorHandlerComp extends SimpleComp {
}
}
private Region createActionButtonGraphic(String nameString, String descString) {
var header = new Label(nameString);
AppFont.header(header);
var desc = new Label(descString);
AppFont.small(desc);
var text = new VBox(header, desc);
text.setSpacing(2);
return text;
}
private Region createActionComp(ErrorAction a) {
var r = JfxHelper.createNamedEntry(a.getName(), a.getDescription());
var r = createActionButtonGraphic(a.getName(), a.getDescription());
var b = new ButtonComp(null, r, () -> {
takenAction.setValue(a);
try {

View file

@ -3,55 +3,14 @@ package io.xpipe.app.util;
import atlantafx.base.controls.Spacer;
import io.xpipe.app.core.AppFont;
import io.xpipe.app.fxcomps.impl.PrettyImageHelper;
import javafx.beans.binding.Bindings;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.kordamp.ikonli.javafx.FontIcon;
public class JfxHelper {
public static Region createNamedEntry(String nameString, String descString) {
var header = new Label(nameString);
AppFont.header(header);
var desc = new Label(descString);
AppFont.small(desc);
var text = new VBox(header, desc);
text.setSpacing(2);
return text;
}
public static Region createNamedEntry(String nameString, String descString, FontIcon graphic) {
var header = new Label(nameString);
var desc = new Label(descString);
AppFont.small(desc);
desc.setOpacity(0.65);
var text = new VBox(header, desc);
text.setSpacing(2);
var pane = new StackPane(graphic);
var hbox = new HBox(pane, text);
hbox.setSpacing(8);
pane.prefWidthProperty()
.bind(Bindings.createDoubleBinding(
() -> (header.getHeight() + desc.getHeight()) * 0.6,
header.heightProperty(),
desc.heightProperty()));
pane.prefHeightProperty()
.bind(Bindings.createDoubleBinding(
() -> header.getHeight() + desc.getHeight() + 2,
header.heightProperty(),
desc.heightProperty()));
pane.prefHeightProperty().addListener((c, o, n) -> {
var size = Math.min(n.intValue(), 100);
graphic.setIconSize((int) (size * 0.55));
});
return hbox;
}
public static Region createNamedEntry(String nameString, String descString, String image) {
var header = new Label(nameString);
AppFont.header(header);

View file

@ -208,8 +208,13 @@
-fx-opacity: 1.0;
}
.browser .quick-access-button {
-fx-border-radius: 0;
-fx-background-radius: 0;
}
.browser .quick-access-button .context-menu .leaf .arrow {
.browser .quick-access-button .context-menu .leaf > * > .arrow {
-fx-pref-width: 0;
-fx-opacity: 0;
}