Merge pull request #329 from AppleCommander/feature/editable-attributes

Feature/editable attributes
This commit is contained in:
A2 Geek authored and GitHub committed 2026-10-17 20:05:06 -05:00
commit a2d910cff6
25 files changed
+1101 -286

No files matched your search

@@ -12,4 +12,5 @@ module org.applecommander.javafx {
opens org.applecommander.javafx to javafx.graphics;
opens org.applecommander.javafx.settings to javafx.graphics;
opens org.applecommander.javafx.wizard to javafx.graphics;
opens org.applecommander.javafx.filter to javafx.graphics;
}
@@ -19,6 +19,8 @@
*/
package org.applecommander.javafx;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
@@ -26,13 +28,16 @@ import javafx.collections.ObservableList;
import javafx.collections.transformation.SortedList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.control.cell.TextFieldTableCell;
import javafx.scene.input.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.util.StringConverter;
import org.applecommander.applesingle.AppleSingle;
import org.applecommander.applesingle.FileDatesInfo;
import org.applecommander.applesingle.ProdosFileInfo;
@@ -44,12 +49,19 @@ import org.applecommander.bastools.api.model.Program;
import org.applecommander.bastools.api.model.Token;
import org.applecommander.capability.Capability;
import org.applecommander.filestore.*;
import org.applecommander.javafx.filter.CharacterRestrictionFilter;
import org.applecommander.javafx.filter.CompositeFilter;
import org.applecommander.javafx.filter.FixedLengthFilter;
import org.applecommander.javafx.settings.ExportOption;
import org.applecommander.javafx.settings.Settings;
import org.applecommander.source.Source;
import org.applecommander.source.Sources;
import org.applecommander.transfer.ProdosAttributePreservation;
import org.applecommander.transfer.ProdosAttributes;
import org.applecommander.ui.ColumnDataType;
import org.applecommander.ui.ColumnOption;
import org.applecommander.ui.DisplayColumn;
import org.applecommander.ui.DisplayMode;
import java.io.ByteArrayOutputStream;
import java.io.File;
@@ -59,6 +71,7 @@ import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.function.UnaryOperator;
import static org.applecommander.javafx.FxUtils.*;
@@ -80,13 +93,16 @@ public class FileView extends BorderPane {
// entry with the Zip renderer. But only when a sort was applied. Clearing out
// every property didn't seem to help. Thus this extreme solution. Please fix!
private TableView<FileEntry> fileTable;
// And the detail grid follows the file table!
private GridPane fileDetailGrid;
private final ObservableList<Directory> directoryPath = FXCollections.observableArrayList();
private final ObjectProperty<Directory> selectedDirectory = new SimpleObjectProperty<>();
private final ObjectProperty<DisplayColumn.Mode> listingMode = new SimpleObjectProperty<>(DisplayColumn.Mode.NATIVE);
private final ObjectProperty<DisplayMode> listingMode = new SimpleObjectProperty<>(DisplayMode.NATIVE);
private final SimpleBooleanProperty supportsDirectories = new SimpleBooleanProperty(false);
private final SimpleBooleanProperty supportsFileDeletion = new SimpleBooleanProperty(false);
private final ObservableList<FileEntry> fileEntries = FXCollections.observableArrayList();
private final BooleanProperty showingDetailProperty = new SimpleBooleanProperty(false);
public FileView(FileStoreWindow fileStoreWindow, ToolBar toolBar) {
this.fileStoreWindow = fileStoreWindow;
@@ -125,7 +141,7 @@ public class FileView extends BorderPane {
if (newValue != null) {
directoryPath.clear();
directoryPath.add(newValue.getRootDirectory());
listingMode.setValue(DisplayColumn.Mode.NATIVE);
listingMode.setValue(DisplayMode.NATIVE);
supportsDirectories.setValue(newValue.can(Capability.SUPPORTS_DIRECTORIES));
supportsFileDeletion.setValue(newValue.can(Capability.DELETE_FILES));
}
@@ -134,8 +150,8 @@ public class FileView extends BorderPane {
fileStoreWindow.viewModeProperty().addListener((_, _, _) -> populateDiskRows());
listingMode.addListener((_, _, newValue) -> {
nativeToolButton.setSelected(newValue == DisplayColumn.Mode.NATIVE);
detailToolButton.setSelected(newValue == DisplayColumn.Mode.DETAIL);
nativeToolButton.setSelected(newValue == DisplayMode.NATIVE);
detailToolButton.setSelected(newValue == DisplayMode.DETAIL);
fileTable.getColumns().forEach(column -> {
if (column.getUserData() instanceof DisplayColumn displayColumn) {
column.visibleProperty().setValue(displayColumn.supports(newValue));
@@ -197,6 +213,28 @@ public class FileView extends BorderPane {
fileTable.setPlaceholder(placeholder);
setCenter(fileTable);
Button detailToggle = FxUtils.createSmallToggleButton("pane-right.png", "pane-left.png", showingDetailProperty);
detailToggle.disableProperty().bind(fileTable.getSelectionModel().selectedItemProperty().isNull());
fileDetailGrid = new GridPane();
ScrollPane scrollPane = new ScrollPane(fileDetailGrid);
scrollPane.setFitToHeight(true);
scrollPane.setStyle("-fx-background-color: transparent;");
scrollPane.visibleProperty().bind(showingDetailProperty);
scrollPane.managedProperty().bind(showingDetailProperty);
VBox detailPane = new VBox(detailToggle, scrollPane);
detailPane.setAlignment(Pos.TOP_LEFT);
detailPane.setSpacing(5);
detailPane.setPadding(new Insets(5));
setRight(detailPane);
fileTable.getSelectionModel().selectedItemProperty().addListener((_, _, newFileEntry) -> {
if (newFileEntry != null) {
populateFileDetailGrid(newFileEntry);
}
});
fileStoreWindow.fileStoreSelection().selectedItemProperty().addListener((_, _, _) -> {
showingDetailProperty.set(false);
});
fileTable.setColumnResizePolicy(TableView.UNCONSTRAINED_RESIZE_POLICY);
fileTable.setItems(FXCollections.emptyObservableList());
fileTable.setOnMouseClicked(this::onMouseClickedHandler);
@@ -207,6 +245,106 @@ public class FileView extends BorderPane {
managedProperty().bind(fileTable.visibleProperty());
}
private void populateFileDetailGrid(FileEntry fileEntry) {
fileDetailGrid.getChildren().clear();
fileDetailGrid.setAlignment(Pos.CENTER_LEFT);
fileDetailGrid.setHgap(10);
fileDetailGrid.setVgap(10);
int row = 0;
SimpleListProperty<Node> editableFields = new SimpleListProperty<>(FXCollections.observableArrayList());
Button applyButton = new Button("Apply");
for (DisplayColumn displayColumn : fileEntry.getFileStore().getDisplayColumns()) {
if (!displayColumn.supports(DisplayMode.DETAIL)) continue;
String text = displayColumn.getTitle();
if (!text.endsWith(":")) text += ":";
Label label = new Label(text);
label.setStyle("-fx-font-weight: bold;");
if (displayColumn.isEditable()) {
if (displayColumn.getOptions().isEmpty()) {
TextField field = new TextField(displayColumn.getDisplayText(fileEntry));
List<UnaryOperator<TextFormatter.Change>> filters = new ArrayList<>();
if (displayColumn.getMaxLength() > 0) {
filters.add(new FixedLengthFilter(displayColumn.getMaxLength()));
}
if (displayColumn.getValidInputPattern() != null) {
filters.add(new CharacterRestrictionFilter(displayColumn.getValidCharacters()));
}
field.setTextFormatter(new TextFormatter<>(new CompositeFilter(filters)));
field.textProperty().addListener((_, _, newText) -> {
if (displayColumn.getValidInputPattern() != null
&& !displayColumn.getValidInputPattern().matcher(newText).matches()) {
field.setStyle("-fx-border-color: red;");
}
else {
field.setStyle("");
}
});
field.setUserData(displayColumn);
fileDetailGrid.addRow(row++, label, field);
editableFields.add(field);
}
else {
ComboBox<ColumnOption> combobox = new ComboBox<>();
combobox.setUserData(displayColumn);
combobox.getItems().addAll(displayColumn.getOptions());
combobox.getSelectionModel().select(displayColumn.getOptions().stream()
.filter(o -> Objects.equals(o.value(), displayColumn.getValue(fileEntry)))
.findAny().orElse(displayColumn.getOptions().getFirst()));
combobox.valueProperty().addListener((_, _, newValue) -> {
combobox.setStyle(newValue == null ? "-fx-border-color: red;" : "");
});
combobox.setConverter(new StringConverter<>() {
@Override
public String toString(ColumnOption option) {
if (option == null) return "";
return String.format("%s - %s", option.value(), option.description());
}
@Override
public ColumnOption fromString(String string) {
return null;
}
});
fileDetailGrid.addRow(row++, label, combobox);
editableFields.add(combobox);
}
}
else {
fileDetailGrid.addRow(row++, label, new Label(displayColumn.getDisplayText(fileEntry)));
}
}
if (!editableFields.isEmpty()) {
// Calculate if it's valid and then negate it. This makes one invalid disable the button.
// We have to handle every control created, however. :shrug:
applyButton.disableProperty().bind(
editableFields.stream().map(node -> switch (node) {
// These define the VALID state
case TextField textField -> textField.textProperty().isNotEmpty()
.and(textField.styleProperty().isEmpty());
case ComboBox<?> comboBox -> comboBox.getSelectionModel().selectedItemProperty().isNotNull()
.and(comboBox.styleProperty().isEmpty());
default -> throw new IllegalStateException("Unexpected value: " + node);
}).reduce(Bindings::and).map(BooleanBinding::not).orElseThrow());
applyButton.setOnAction(event -> {
for (Node node : editableFields) {
DisplayColumn column = (DisplayColumn) node.getUserData();
switch (node) {
case TextField textField -> {
column.setValue(fileEntry, textField.getText());
}
case ComboBox<?> comboBox -> {
ColumnOption option = (ColumnOption) comboBox.getSelectionModel().getSelectedItem();
column.setValue(fileEntry, option.value());
}
default -> { /* do nothing */ }
}
}
fileStoreWindow.addChange();
});
fileDetailGrid.addRow(row++, applyButton);
}
}
private void onDragDroppedHandler(DragEvent event) {
Dragboard db = event.getDragboard();
boolean success = false;
@@ -258,7 +396,7 @@ public class FileView extends BorderPane {
}
success = true;
}
} catch (IOException e) {
} catch (Throwable e) {
FxUtils.showErrorDialog("Unable to drop files", e);
}
event.setDropCompleted(success);
@@ -383,7 +521,7 @@ public class FileView extends BorderPane {
public void clear() {
directoryPath.clear();
selectedDirectory.set(null);
listingMode.setValue(DisplayColumn.Mode.NATIVE);
listingMode.setValue(DisplayMode.NATIVE);
deletedFilesToggleButton.setSelected(false);
//createNewFileTable();
fileTable.getSelectionModel().clearSelection();
@@ -392,11 +530,11 @@ public class FileView extends BorderPane {
}
private void selectNativeView() {
listingMode.set(DisplayColumn.Mode.NATIVE);
listingMode.set(DisplayMode.NATIVE);
}
private void selectDetailView() {
listingMode.set(DisplayColumn.Mode.DETAIL);
listingMode.set(DisplayMode.DETAIL);
}
private void populateDiskRows() {
@@ -416,29 +554,42 @@ public class FileView extends BorderPane {
boolean editable = false;
List<TableColumn<FileEntry,?>> tableColumns = new ArrayList<>();
for (final DisplayColumn displayColumn : displayColumns) {
TableColumn<FileEntry,?> column = switch (displayColumn.dataType()) {
TableColumn<FileEntry,?> column = switch (displayColumn.getDataType()) {
case STRING -> {
TableColumn<FileEntry,String> stringColumn = new TableColumn<>(displayColumn.headerText());
if (displayColumn.editInline()) {
stringColumn.setCellFactory(TextFieldTableCell.forTableColumn());
TableColumn<FileEntry,String> stringColumn = new TableColumn<>(displayColumn.getTitle());
stringColumn.setCellValueFactory(cell ->
new SimpleStringProperty(displayColumn.getDisplayText(cell.getValue())));
if (displayColumn.canEditInLine()) {
stringColumn.setCellFactory(TextFieldTableCell.forTableColumn(new StringConverter<>() {
@Override
public String toString(String object) {
if (object == null) {
return "";
}
if (displayColumn.getValidCharacters() != null) {
object = displayColumn.getValidCharacters().map(object);
}
if (displayColumn.getMaxLength() > 0 && object.length() > displayColumn.getMaxLength()) {
object = object.substring(0, displayColumn.getMaxLength());
}
return object;
}
@Override
public String fromString(String string) {
return string;
}
}));
stringColumn.setEditable(true);
stringColumn.setCellValueFactory(cell -> {
SimpleStringProperty property = new SimpleStringProperty(displayColumn.formatAsText(cell.getValue()));
property.addListener((_, _, newValue) -> {
displayColumn.setValueFn().accept(cell.getValue(), newValue);
fileStoreWindow.addChange();
});
return property;
stringColumn.setOnEditCommit(event -> {
displayColumn.setValue(event.getRowValue(), event.getNewValue());
});
editable = true;
} else {
stringColumn.setCellValueFactory(cell ->
new SimpleStringProperty(displayColumn.formatAsText(cell.getValue())));
}
yield stringColumn;
}
case INTEGER,LONG,DOUBLE -> {
TableColumn<FileEntry,Number> numberColumn = new TableColumn<>(displayColumn.headerText());
case INTEGER,REAL -> {
TableColumn<FileEntry,Number> numberColumn = new TableColumn<>(displayColumn.getTitle());
numberColumn.setCellFactory(c -> new TableCell<>() {
@Override
protected void updateItem(Number item, boolean empty) {
@@ -446,35 +597,31 @@ public class FileView extends BorderPane {
if (empty || item == null) {
setText(null);
} else {
setText(String.format(displayColumn.fmt(), item));
setText(displayColumn.formatObject(item));
}
}
});
switch (displayColumn.dataType()) {
case INTEGER:
numberColumn.setCellValueFactory(cell ->
new SimpleIntegerProperty((Integer)displayColumn.getValueFn().apply(cell.getValue())));
break;
case LONG:
numberColumn.setCellValueFactory(cell ->
new SimpleLongProperty((Long)displayColumn.getValueFn().apply(cell.getValue())));
break;
case DOUBLE:
numberColumn.setCellValueFactory(cell ->
new SimpleDoubleProperty((Double)displayColumn.getValueFn().apply(cell.getValue())));
break;
if (displayColumn.getDataType() == ColumnDataType.REAL) {
numberColumn.setCellValueFactory(cell -> {
Number number = (Number) displayColumn.getValue(cell.getValue());
return new SimpleDoubleProperty(number.doubleValue());
});
}
else {
numberColumn.setCellValueFactory(cell -> {
Number number = (Number) displayColumn.getValue(cell.getValue());
return new SimpleLongProperty(number.longValue());
});
}
yield numberColumn;
}
};
column.setUserData(displayColumn);
column.visibleProperty().setValue(displayColumn.supports(listingMode.get()));
if (displayColumn.alignment() == DisplayColumn.Alignment.RIGHT) {
column.setStyle("-fx-alignment: CENTER-RIGHT;");
} else if (displayColumn.alignment() == DisplayColumn.Alignment.CENTER) {
column.setStyle("-fx-alignment: CENTER;");
} else {
column.setStyle("-fx-alignment: CENTER-LEFT;");
switch (displayColumn.getAlignment()) {
case RIGHT -> column.setStyle("-fx-alignment: CENTER-RIGHT;");
case CENTER -> column.setStyle("-fx-alignment: CENTER;");
case LEFT -> column.setStyle("-fx-alignment: CENTER-LEFT;");
}
tableColumns.add(column);
}
@@ -497,6 +644,9 @@ public class FileView extends BorderPane {
fileStoreWindow.addChange();
}
}
else if (event.isShortcutDown() && event.getCode() == KeyCode.I) {
showingDetailProperty.set(!showingDetailProperty.get());
}
});
List<? extends FileEntry> rows = directory.getFiles().stream()
@@ -510,8 +660,8 @@ public class FileView extends BorderPane {
fileTable.getColumns().addAll(tableColumns);
sortedRows.comparatorProperty().bind(fileTable.comparatorProperty());
// If selection is bound, the ToolButton crashes and burns, so need to manage it manually.
nativeToolButton.setSelected(listingMode.get() == DisplayColumn.Mode.NATIVE);
detailToolButton.setSelected(listingMode.get() == DisplayColumn.Mode.DETAIL);
nativeToolButton.setSelected(listingMode.get() == DisplayMode.NATIVE);
detailToolButton.setSelected(listingMode.get() == DisplayMode.DETAIL);
}
private void toggleDeletedFiles() {
@@ -19,6 +19,7 @@
*/
package org.applecommander.javafx;
import javafx.beans.property.BooleanProperty;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Pos;
@@ -48,6 +49,7 @@ public class FxUtils {
Button button = new Button();
button.setGraphic(vbox);
button.setOnAction(eventHandler);
button.setFocusTraversable(false);
return button;
}
@@ -64,6 +66,7 @@ public class FxUtils {
button.setGraphic(vbox);
button.setOnAction(eventHandler);
button.setToggleGroup(toggleGroup);
button.setFocusTraversable(false);
return button;
}
@@ -78,6 +81,7 @@ public class FxUtils {
ToggleButton button = new ToggleButton();
button.setGraphic(vbox);
button.setOnAction(eventHandler);
button.setFocusTraversable(false);
button.selectedProperty().addListener((_, _, _) -> {
String imageUrl = button.isSelected() ? urlOn : urlOff;
imageView.setImage(new Image(imageUrl(imageUrl)));
@@ -85,6 +89,25 @@ public class FxUtils {
return button;
}
public static Button createSmallToggleButton(String urlOff, String urlOn, BooleanProperty toggleState) {
toggleState.setValue(false);
ImageView imageView = new ImageView(imageUrl(urlOff));
imageView.setFitHeight(16);
imageView.setFitWidth(16);
imageView.setPreserveRatio(true);
Button button = new Button();
button.setFocusTraversable(false);
button.setGraphic(imageView);
button.setOnAction(event -> {
toggleState.setValue(!toggleState.getValue());
});
toggleState.addListener((_, _, _) -> {
String imageUrl = toggleState.get() ? urlOn : urlOff;
imageView.setImage(new Image(imageUrl(imageUrl)));
});
return button;
}
public static String imageUrl(String imageName) {
return Objects.requireNonNull(FxUtils.class.getResource("/images/" + imageName)).toExternalForm();
}
@@ -103,7 +126,9 @@ public class FxUtils {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
t.printStackTrace(pw);
alert.setContentText(sw.toString());
TextArea textArea = new TextArea(sw.toString());
textArea.setEditable(false);
alert.getDialogPane().setExpandableContent(textArea);
alert.showAndWait();
}
@@ -0,0 +1,41 @@
/*
* AppleCommander - An Apple ][ image utility.
* Copyright (C) 2026 by Robert Greene and others
* robgreene at users.sourceforge.net
*
* This program 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 2 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, write to the Free Software Foundation, Inc.,
* 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package org.applecommander.javafx.filter;
import javafx.scene.control.TextFormatter;
import org.applecommander.ui.CharacterMapper;
import java.util.function.UnaryOperator;
public class CharacterRestrictionFilter implements UnaryOperator<TextFormatter.Change> {
private final CharacterMapper characterMapper;
public CharacterRestrictionFilter(CharacterMapper characterMapper) {
this.characterMapper = characterMapper;
}
@Override
public TextFormatter.Change apply(TextFormatter.Change change) {
if (change.isAdded()) {
change.setText(characterMapper.map(change.getText()));
}
return change;
}
}
@@ -0,0 +1,44 @@
/*
* AppleCommander - An Apple ][ image utility.
* Copyright (C) 2026 by Robert Greene and others
* robgreene at users.sourceforge.net
*
* This program 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 2 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, write to the Free Software Foundation, Inc.,
* 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package org.applecommander.javafx.filter;
import javafx.scene.control.TextFormatter;
import java.util.List;
import java.util.function.UnaryOperator;
public class CompositeFilter implements UnaryOperator<TextFormatter.Change> {
private final List<UnaryOperator<TextFormatter.Change>> filters;
public CompositeFilter(List<UnaryOperator<TextFormatter.Change>> filters) {
this.filters = filters;
}
@Override
public TextFormatter.Change apply(TextFormatter.Change change) {
for (UnaryOperator<TextFormatter.Change> filter : filters) {
change = filter.apply(change);
if (change == null) {
break;
}
}
return change;
}
}
@@ -0,0 +1,41 @@
/*
* AppleCommander - An Apple ][ image utility.
* Copyright (C) 2026 by Robert Greene and others
* robgreene at users.sourceforge.net
*
* This program 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 2 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, write to the Free Software Foundation, Inc.,
* 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package org.applecommander.javafx.filter;
import javafx.scene.control.TextFormatter;
import java.util.function.UnaryOperator;
// See: https://www.reddit.com/r/JavaFX/comments/qd5uds/how_do_you_limit_the_character_input_in_a/
public class FixedLengthFilter implements UnaryOperator<TextFormatter.Change> {
private final int maxLength;
public FixedLengthFilter(int maxLength) {
this.maxLength = maxLength;
}
@Override
public TextFormatter.Change apply(TextFormatter.Change change) {
if (change.getControlNewText().length() > maxLength) {
return null;
}
return change;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -41,6 +41,8 @@ import org.applecommander.device.TrackSectorDevice;
import org.applecommander.filestore.*;
import org.applecommander.filestore.FileEntry;
import org.applecommander.transfer.ProdosAttributes;
import org.applecommander.ui.DisplayColumn;
import org.applecommander.ui.DisplayMode;
import org.applecommander.usage.BlockUsage;
import org.applecommander.usage.DiskUsage;
import org.applecommander.usage.DiskUsage.UsageType;
@@ -145,35 +147,32 @@ public class DiskFileStoreAdapter implements FileStore {
/// old API pre-formats everything as a String.
@Override
public List<DisplayColumn> getDisplayColumns() {
List<DisplayColumn> columns = new ArrayList<>();
DisplayColumn.Builder<DiskFileEntryAdapter> builder = DisplayColumn.builder(DiskFileEntryAdapter.class);
for (int displayMode : List.of(FormattedDisk.FILE_DISPLAY_NATIVE,
FormattedDisk.FILE_DISPLAY_DETAIL)) {
List<FormattedDisk.FileColumnHeader> headers = disk.getFileColumnHeaders(displayMode);
DisplayColumn.Mode mode = DisplayColumn.Mode.DETAIL;
DisplayMode mode = DisplayMode.DETAIL;
if (displayMode == FormattedDisk.FILE_DISPLAY_NATIVE) {
mode = DisplayColumn.Mode.NATIVE;
};
mode = DisplayMode.NATIVE;
}
for (int i = 0; i<headers.size(); i++) {
var header = headers.get(i);
DisplayColumn.Alignment alignment = switch(header.getAlignment()) {
case FormattedDisk.FileColumnHeader.ALIGN_CENTER -> DisplayColumn.Alignment.CENTER;
case FormattedDisk.FileColumnHeader.ALIGN_RIGHT -> DisplayColumn.Alignment.RIGHT;
default -> DisplayColumn.Alignment.LEFT;
};
String title = header.getTitle();
if (title == null || title.trim().isEmpty()) {
title = "Locked?";
}
final int headerIndex = i;
Function<FileEntry,String> mappingFn = entry -> {
// TODO investigate to see if generics work across these interfaces
if (entry instanceof DiskFileEntryAdapter fileEntryAdapter) {
return fileEntryAdapter.fileEntry.getFileColumnData(displayMode).get(headerIndex);
}
throw new RuntimeException("Unexpected file entry type: " + entry.getClass().getName());
Function<DiskFileEntryAdapter,String> getValueFn = entry ->
entry.fileEntry.getFileColumnData(displayMode).get(headerIndex);
DisplayColumn.ColumnBuilder<DiskFileEntryAdapter> column = switch (header.getAlignment()) {
case FormattedDisk.FileColumnHeader.ALIGN_CENTER -> builder.center(title);
case FormattedDisk.FileColumnHeader.ALIGN_RIGHT -> builder.right(title);
default -> builder.left(title);
};
DisplayColumn displayColumn = new DisplayColumn(header.getTitle(),
alignment, DisplayColumn.DataType.STRING, mappingFn::apply, null, false, "%s", mode);
columns.add(displayColumn);
column.string(getValueFn).mode(mode).done();
}
}
return columns;
return builder.toList();
}
@Override
@@ -55,6 +55,7 @@ module org.applecommander.api {
exports org.applecommander.os.pascal;
exports org.applecommander.source;
exports org.applecommander.transfer;
exports org.applecommander.ui;
exports org.applecommander.usage;
exports org.applecommander.util;
@@ -20,8 +20,12 @@
package org.applecommander.archive.aaf;
import org.applecommander.capability.Capability;
import org.applecommander.filestore.*;
import org.applecommander.filestore.Directory;
import org.applecommander.filestore.FileEntry;
import org.applecommander.filestore.FileStore;
import org.applecommander.filestore.GenericDirectory;
import org.applecommander.source.Source;
import org.applecommander.ui.DisplayColumn;
import org.applecommander.util.Container;
import org.applecommander.util.InformationGroup;
@@ -94,8 +98,8 @@ public class AppleArchiveFormatFileStore implements FileStore {
@Override
public List<DisplayColumn> getDisplayColumns() {
return DisplayColumn.builder(AppleArchiveFormatFileEntry.class)
.addStringField("Name", AppleArchiveFormatFileEntry::getName)
.addLongField("Size", AppleArchiveFormatFileEntry::getSize)
.left("Name").string(AppleArchiveFormatFileEntry::getName).done()
.right("Size").integer(AppleArchiveFormatFileEntry::getSize, "%,d").done()
.toList();
}
@@ -20,12 +20,15 @@
package org.applecommander.archive.shrinkit;
import org.applecommander.capability.Capability;
import org.applecommander.filestore.*;
import org.applecommander.filestore.DisplayColumn.Alignment;
import org.applecommander.filestore.DisplayColumn.Mode;
import org.applecommander.filestore.Directory;
import org.applecommander.filestore.FileEntry;
import org.applecommander.filestore.FileStore;
import org.applecommander.filestore.GenericDirectory;
import org.applecommander.shrinkit.MasterHeaderBlock;
import org.applecommander.shrinkit.NuFileArchive;
import org.applecommander.source.Source;
import org.applecommander.ui.DisplayColumn;
import org.applecommander.ui.DisplayMode;
import org.applecommander.util.Container;
import org.applecommander.util.InformationGroup;
@@ -78,21 +81,21 @@ public class ShrinkitFileStore implements FileStore {
@Override
public List<DisplayColumn> getDisplayColumns() {
return DisplayColumn.builder(ShrinkitFileEntry.class)
.addStringField("Name", ShrinkitFileEntry::getName)
.addStringField("Sys. Id", ShrinkitFileEntry::getFileSysIdText, Mode.DETAIL)
.addIntField("File Sys. Info", ShrinkitFileEntry::getFileSysInfo, "$%04X", Mode.DETAIL)
.addStringField("Access", Alignment.CENTER, ShrinkitFileEntry::getAccessText, Mode.DETAIL)
.addStringField("Kind", ShrinkitFileEntry::getStorageTypeText)
.addStringField("Type", Alignment.CENTER, ShrinkitFileEntry::getFileTypeText)
.addLongField("Aux. Type", ShrinkitFileEntry::getExtraType, "$%04X")
.addDateField("Archived", ShrinkitFileEntry::getArchiveWhen)
.addDateField("Created", ShrinkitFileEntry::getCreateWhen, Mode.DETAIL)
.addDateField("Modified", ShrinkitFileEntry::getModWhen, Mode.DETAIL)
.addStringField("Format", ShrinkitFileEntry::getThreadFormatText)
.addPercentField("Size", ShrinkitFileEntry::getCompressedSize, ShrinkitFileEntry::getSize, "%2.0f%%")
.addLongField("Un-Length", ShrinkitFileEntry::getSize)
.addIntField("Data CRC", ShrinkitFileEntry::getDataForkCrc, "$%04X", Mode.DETAIL)
.addIntField("Resource CRC", ShrinkitFileEntry::getResourceForkCrc, "$%04X", Mode.DETAIL)
.left("Name").string(ShrinkitFileEntry::getName).done()
.left("Sys. Id").string(ShrinkitFileEntry::getFileSysIdText).mode(DisplayMode.DETAIL).done()
.right("File Sys. Info").integer(ShrinkitFileEntry::getFileSysInfo, "$%04X").mode(DisplayMode.DETAIL).done()
.center("Access").string(ShrinkitFileEntry::getAccessText).mode(DisplayMode.DETAIL).done()
.left("Kind").string(ShrinkitFileEntry::getStorageTypeText).done()
.center("Type").string(ShrinkitFileEntry::getFileTypeText).done()
.right("Aux. Type").integer(ShrinkitFileEntry::getExtraType, "$%04X").done()
.center("Archived").date(ShrinkitFileEntry::getArchiveWhen).done()
.center("Created").date(ShrinkitFileEntry::getCreateWhen).mode(DisplayMode.DETAIL).done()
.center("Modified").date(ShrinkitFileEntry::getModWhen).mode(DisplayMode.DETAIL).done()
.left("Format").string(ShrinkitFileEntry::getThreadFormatText).done()
.right("Size").percent(ShrinkitFileEntry::getCompressedSize, ShrinkitFileEntry::getSize, "%2.0f%%").done()
.right("Un-Length").integer(ShrinkitFileEntry::getSize, "%,d").done()
.right("Data CRC").integer(ShrinkitFileEntry::getDataForkCrc, "$%04X").mode(DisplayMode.DETAIL).done()
.right("Resource CRC").integer(ShrinkitFileEntry::getResourceForkCrc, "$%04X").mode(DisplayMode.DETAIL).done()
.toList();
}
@@ -20,11 +20,11 @@
package org.applecommander.archive.zip;
import org.applecommander.capability.Capability;
import org.applecommander.filestore.DisplayColumn;
import org.applecommander.filestore.DisplayColumn.Mode;
import org.applecommander.filestore.FileEntry;
import org.applecommander.filestore.FileStore;
import org.applecommander.source.Source;
import org.applecommander.ui.DisplayColumn;
import org.applecommander.ui.DisplayMode;
import org.applecommander.util.Container;
import org.applecommander.util.DataBuffer;
import org.applecommander.util.InformationGroup;
@@ -40,6 +40,7 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import static org.applecommander.ui.DisplayColumn.builder;
import static org.applecommander.util.Shorthand.copy;
public class ZipFileStore implements FileStore {
@@ -90,16 +91,16 @@ public class ZipFileStore implements FileStore {
@Override
public List<DisplayColumn> getDisplayColumns() {
return DisplayColumn.builder(ZipFileEntry.class)
.addLongField("Length", FileEntry::getSize)
.addStringField("Method", ZipFileEntry::getMethodName, Mode.DETAIL)
.addLongField("Size", ZipFileEntry::getCompressedSize, Mode.DETAIL)
.addPercentField("Ratio", ZipFileEntry::getCompressedSize, ZipFileEntry::getSize, "%2.0f%%", Mode.DETAIL)
.addFileTimeField("Creation", ZipFileEntry::getCreationTime)
.addFileTimeField("Last Access", ZipFileEntry::getLastAccessTime, Mode.DETAIL)
.addFileTimeField("Last Modified", ZipFileEntry::getLastModifiedTime)
.addLongField("CRC-32", ZipFileEntry::getCrc, "%08x", Mode.DETAIL)
.addStringField("Name", FileEntry::getName)
return builder(ZipFileEntry.class)
.right("Length").integer(FileEntry::getSize, "%,d").done()
.left("Method").string(ZipFileEntry::getMethodName).mode(DisplayMode.DETAIL).done()
.right("Size").integer(ZipFileEntry::getCompressedSize, "%,d").mode(DisplayMode.DETAIL).done()
.right("Ratio").percent(ZipFileEntry::getCompressedSize, ZipFileEntry::getSize, "%2.0f%%").mode(DisplayMode.DETAIL).done()
.center("Creation").fileTime(ZipFileEntry::getCreationTime).done()
.center("Last Access").fileTime(ZipFileEntry::getLastAccessTime).mode(DisplayMode.DETAIL).done()
.center("Last Modified").fileTime(ZipFileEntry::getLastModifiedTime).done()
.right("CRC-32").integer(ZipFileEntry::getCrc, "%08x").mode(DisplayMode.DETAIL).done()
.left("Name").string(FileEntry::getName).editInLine().done()
.toList();
}
@@ -1,174 +0,0 @@
/*
* AppleCommander - An Apple ][ image utility.
* Copyright (C) 2026 by Robert Greene and others
* robgreene at users.sourceforge.net
*
* This program 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 2 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, write to the Free Software Foundation, Inc.,
* 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package org.applecommander.filestore;
import java.nio.file.attribute.FileTime;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Function;
/// A DisplayColumn supports formatted output from various FileStores in a
/// dynamic fashion. The intent is to provide a flexible display interface
/// that does not rely on hard-coded implementations.
public record DisplayColumn(String headerText, Alignment alignment, DataType dataType,
Function<FileEntry,Object> getValueFn, BiConsumer<FileEntry,Object> setValueFn,
boolean editInline, String fmt, Mode... modes) {
/// Indicates if this DisplayColumn supports the mode. In this manner, the columns
/// can be filtered out. Or it can be ignored to display everything.
public boolean supports(Mode mode) {
return Set.of(modes).contains(mode);
}
/// Helper method to convert the column into a formatted string for display.
public String formatAsText(FileEntry fileEntry) {
return String.format(fmt, getValueFn.apply(fileEntry));
}
/// Indicates the display mode to help filter columns to users' preference.
public enum Mode {
NATIVE,
DETAIL
}
/// Indicates how this column should be aligned.
public enum Alignment {
LEFT,
CENTER,
RIGHT
}
/// Indicates the data type to help with user interfaces.
/// (Handling this via typing got too twisty to follow, and it was abandoned.)
/// Note that Date and FileTime are currently converted to Strings.
public enum DataType {
STRING,
INTEGER,
LONG,
DOUBLE
}
public static <T extends FileEntry> Builder<T> builder(Class<T> clazz) {
return new Builder<>(clazz);
}
public static class Builder<T extends FileEntry> {
private static final DateTimeFormatter FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm:ss");
private final Class<T> clazz;
private final List<DisplayColumn> columns = new ArrayList<>();
private Builder(Class<T> clazz) {
this.clazz = clazz;
}
public List<DisplayColumn> toList() {
return columns;
}
/// A helper method that allows a short-cut of 0 modes meaning ALL modes.
private Builder<T> add(String name, Alignment alignment, DataType dataType,
Function<FileEntry,Object> getValueFn, BiConsumer<FileEntry,Object> setValueFn,
boolean editInline, String fmt, Mode... modes) {
if (modes.length == 0) {
modes = new Mode[] { Mode.NATIVE, Mode.DETAIL };
}
columns.add(new DisplayColumn(name, alignment, dataType, getValueFn, setValueFn, editInline, fmt, modes));
return this;
}
private Builder<T> add(String name, Alignment alignment, DataType dataType, Function<FileEntry,Object> getValueFn,
String fmt, Mode... modes) {
return add(name, alignment, dataType, getValueFn, null, false, fmt, modes);
}
public Builder<T> addIntField(String name, Function<T,Integer> valueFn, Mode ...modes) {
return addIntField(name, valueFn, "%d", modes);
}
public Builder<T> addIntField(String name, Function<T,Integer> valueFn, String fmt, Mode ...modes) {
return add(name, Alignment.RIGHT, DataType.INTEGER, entry -> convert(entry, valueFn), fmt, modes);
}
public Builder<T> addStringField(String name, Function<T,String> getValueFn, BiConsumer<T,String> setValueFn,
boolean editInline, Mode ...modes) {
return add(name, Alignment.LEFT, DataType.STRING, entry -> convert(entry, getValueFn),
(entry,value) -> convert(entry, setValueFn, String.class, value),
editInline, "%s", modes);
}
public Builder<T> addStringField(String name, Function<T,String> valueFn, Mode ...modes) {
return add(name, Alignment.LEFT, DataType.STRING, entry -> convert(entry, valueFn), "%s", modes);
}
public Builder<T> addStringField(String name, Alignment alignment, Function<T,String> valueFn, Mode ...modes) {
return add(name, alignment, DataType.STRING, entry -> convert(entry, valueFn), "%s", modes);
}
public Builder<T> addPercentField(String name, Function<T,Number> numeratorFn, Function<T,Number> denominatorFn, String fmt, Mode ...modes) {
return add(name, Alignment.RIGHT, DataType.DOUBLE, entry -> percentage(entry, numeratorFn, denominatorFn), fmt, modes);
}
public Builder<T> addFileTimeField(String name, Function<T,FileTime> valueFn, Mode ...modes) {
return add(name, Alignment.CENTER, DataType.STRING, entry -> formatFileTime(entry, valueFn), "%s", modes);
}
public Builder<T> addLongField(String name, Function<T,Long> valueFn, Mode ...modes) {
return addLongField(name, valueFn, "%d", modes);
}
public Builder<T> addLongField(String name, Function<T,Long> valueFn, String fmt, Mode ...modes) {
return add(name, Alignment.RIGHT, DataType.LONG, entry -> convert(entry, valueFn), fmt, modes);
}
public Builder<T> addDateField(String name, Function<T,Date> valueFn, Mode ...modes) {
return add(name, Alignment.CENTER, DataType.STRING, entry -> formatDate(entry, valueFn), "%s", modes);
}
private <S> S convert(FileEntry fileEntry, Function<T,S> valueFn) {
T typedFileEntry = clazz.cast(fileEntry);
return valueFn.apply(typedFileEntry);
}
private <S> void convert(FileEntry fileEntry, BiConsumer<T,S> setValueFn, Class<S> valueClass, Object value) {
T typedFileEntry = clazz.cast(fileEntry);
S typedValue = valueClass.cast(value);
setValueFn.accept(typedFileEntry, typedValue);
}
private double percentage(FileEntry fileEntry, Function<T,Number> numeratorFn, Function<T,Number> denominatorFn) {
T typedFileEntry = clazz.cast(fileEntry);
Number numerator = numeratorFn.apply(typedFileEntry);
Number denominator = denominatorFn.apply(typedFileEntry);
return (1.0 - numerator.doubleValue() / denominator.doubleValue()) * 100.0;
}
private String formatFileTime(FileEntry fileEntry, Function<T,FileTime> valueFn) {
T typedFileEntry = clazz.cast(fileEntry);
// https://mkyong.com/java/how-to-format-filetime-in-java/
FileTime fileTime = valueFn.apply(typedFileEntry);
if (fileTime == null) {
return "- No Date -";
}
LocalDateTime localDateTime = fileTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
return localDateTime.format(FILE_TIME_FORMATTER);
}
private String formatDate(FileEntry fileEntry, Function<T, Date> valueFn) {
T typedFileEntry = clazz.cast(fileEntry);
Date date = valueFn.apply(typedFileEntry);
if (date == null) {
return "- No Date -";
}
LocalDateTime localDateTime = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
return localDateTime.format(FILE_TIME_FORMATTER);
}
}
}
@@ -20,6 +20,7 @@
package org.applecommander.filestore;
import org.applecommander.capability.CapabilityProvider;
import org.applecommander.ui.DisplayColumn;
import org.applecommander.util.Container;
import org.applecommander.util.InformationProvider;
@@ -21,9 +21,10 @@ package org.applecommander.os.gamedos;
import org.applecommander.capability.Capability;
import org.applecommander.device.TrackSectorDevice;
import org.applecommander.filestore.DisplayColumn;
import org.applecommander.filestore.FileEntry;
import org.applecommander.filestore.FileStore;
import org.applecommander.ui.DisplayColumn;
import org.applecommander.ui.DisplayMode;
import org.applecommander.usage.DiskUsage.UsageType;
import org.applecommander.usage.SectorUsage;
import org.applecommander.util.Container;
@@ -33,8 +34,6 @@ import org.applecommander.util.InformationGroup;
import java.util.List;
import java.util.Optional;
import static org.applecommander.filestore.DisplayColumn.Mode;
public class GamedosFileStore implements FileStore, GamedosConstants {
private final TrackSectorDevice device;
private final GamedosDirectory rootDirectory;
@@ -108,13 +107,22 @@ public class GamedosFileStore implements FileStore, GamedosConstants {
@Override
public List<DisplayColumn> getDisplayColumns() {
return DisplayColumn.builder(GamedosFileEntry.class)
.addStringField("Type", GamedosFileEntry::getFiletype, GamedosFileEntry::setFiletype, false)
.addStringField("Name", GamedosFileEntry::getName, GamedosFileEntry::setName, true)
.addIntField("Meta", GamedosFileEntry::getMeta, "$%04X")
.addIntField("First Track", GamedosFileEntry::getFirstTrack, Mode.DETAIL)
.addIntField("First Sector", GamedosFileEntry::getFirstSector, Mode.DETAIL)
.addIntField("Sector Count", GamedosFileEntry::getSectorCount, Mode.DETAIL)
.addLongField("Size", GamedosFileEntry::getSize)
.left("Type").string(GamedosFileEntry::getFiletype, GamedosFileEntry::setFiletype)
.option("A", "Applesoft BASIC")
.option("B", "Binary")
.option("I", "Integer BASIC")
.option("P", "Picture")
.option("S", "System")
.option("T", "Text")
.done()
.left("Name").string(GamedosFileEntry::getName, GamedosFileEntry::setName)
.editInLine().maxLength(26).done()
.right("Meta").integer(GamedosFileEntry::getMeta, "$%04X", GamedosFileEntry::setMeta)
.restrictToHexValues().done()
.right("First Track").integer(GamedosFileEntry::getFirstTrack, "%d").mode(DisplayMode.DETAIL).done()
.right("First Sector").integer(GamedosFileEntry::getFirstSector, "%d").mode(DisplayMode.DETAIL).done()
.right("Sector Count").integer(GamedosFileEntry::getSectorCount, "%d").mode(DisplayMode.DETAIL).done()
.right("Size").integer(GamedosFileEntry::getSize, "%,d").done()
.toList();
}
@@ -0,0 +1,94 @@
/*
* AppleCommander - An Apple ][ image utility.
* Copyright (C) 2026 by Robert Greene and others
* robgreene at users.sourceforge.net
*
* This program 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 2 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, write to the Free Software Foundation, Inc.,
* 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package org.applecommander.ui;
/// `CharacterMapper` utility class that is used to convert an input string into a set of
/// valid characters. This can handle both lower-case to upper-case mappings and
/// restricting input characters or doing things like mapping a ' ' to a '.'.
///
/// Example usage:
/// ```
/// CharacterMapper hex = CharacterMapper.builder()
/// .mapRange('a', 'f', true)
/// .mapRange('A', 'F', false)
/// .mapRange('0', '9', false)
/// .map('$', '$')
/// .build();
///
/// System.out.println(hex.map(">>> $12af <<<"));
/// ```
/// ... yields a `$12AF` output.
public class CharacterMapper {
private final String fromChars;
private final String toChars;
private CharacterMapper(String fromChars, String toChars) {
this.fromChars = fromChars;
this.toChars = toChars;
}
public String map(String inputString) {
// No restrictions
if (fromChars.isBlank() && toChars.isBlank()) {
return inputString;
}
// Copy valid characters ("fromChars") over to the modified string ("toChars")
StringBuilder sb = new StringBuilder();
for (char ch : inputString.toCharArray()) {
int pos = fromChars.indexOf(ch);
if (pos != -1) {
sb.append(toChars.charAt(pos));
}
}
return sb.toString();
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private final StringBuilder fromChars = new StringBuilder();
private final StringBuilder toChars = new StringBuilder();
/// Add a range of characters
public Builder mapRange(char first, char last, boolean forceUppercase) {
StringBuilder string = new StringBuilder();
for (char ch = first; ch <= last; ch++) string.append(ch);
String alpha = string.toString();
fromChars.append(alpha);
if (forceUppercase) alpha = alpha.toUpperCase();
toChars.append(alpha);
return this;
}
/// Add a single character mapping
public Builder map(char from, char to) {
fromChars.append(from);
toChars.append(to);
return this;
}
public CharacterMapper build() {
if (fromChars.length() != toChars.length()) {
throw new RuntimeException("developer error - character mappings don't match");
}
return new CharacterMapper(fromChars.toString(), toChars.toString());
}
}
}
@@ -0,0 +1,27 @@
/*
* AppleCommander - An Apple ][ image utility.
* Copyright (C) 2026 by Robert Greene and others
* robgreene at users.sourceforge.net
*
* This program 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 2 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, write to the Free Software Foundation, Inc.,
* 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package org.applecommander.ui;
/// Indicates how this column should be aligned.
public enum ColumnAlignment {
LEFT,
CENTER,
RIGHT
}
@@ -0,0 +1,29 @@
/*
* AppleCommander - An Apple ][ image utility.
* Copyright (C) 2026 by Robert Greene and others
* robgreene at users.sourceforge.net
*
* This program 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 2 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, write to the Free Software Foundation, Inc.,
* 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package org.applecommander.ui;
public enum ColumnDataType {
/// Represents strings or things that are converted to strings.
STRING,
/// Represents numbers without decimal places: `byte`, `short`, `int`, `long`.
INTEGER,
/// Represents numbers _with_ decimal places: `float`, `double`.
REAL
}
@@ -0,0 +1,23 @@
/*
* AppleCommander - An Apple ][ image utility.
* Copyright (C) 2026 by Robert Greene and others
* robgreene at users.sourceforge.net
*
* This program 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 2 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, write to the Free Software Foundation, Inc.,
* 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package org.applecommander.ui;
public record ColumnOption(String value, String description) {
}
@@ -0,0 +1,381 @@
/*
* AppleCommander - An Apple ][ image utility.
* Copyright (C) 2026 by Robert Greene and others
* robgreene at users.sourceforge.net
*
* This program 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 2 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, write to the Free Software Foundation, Inc.,
* 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package org.applecommander.ui;
import org.applecommander.filestore.FileEntry;
import java.nio.file.attribute.FileTime;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.regex.Pattern;
/// A `DisplayColumn` supports formatted output from various `FileStore`s in a
/// dynamic fashion. The intent is to provide a flexible display interface
/// across interfaces that does not rely on hard-coded implementations.
///
/// The `Builder` should be utilized to construct the `DisplayColumn`.
///
/// The read-only settings include:
/// * `title` is the column title
/// * `alignment` is the alignment of the column data
/// * `mode` is the display mode that the column applies to (native, detail, or both)
/// * `columnDataType` is the (simplified) data type
/// * `getValueFn` is the method reference to get the column's value
/// * `formatFn` is the function that takes the column's value and prepares it for display
///
/// The editable attributes are:
/// * `setValueFn` is the method reference to set the value; note that the builder wraps
/// the actual setter with a conversion function
/// * `editInLine` indicates if this specific cell can be edited in a grid; at this time
/// this is only used for the name field
/// * `maxLength` is the maximum length of the input
/// * `options` are the options available for selection
/// * `validCharacters` is a `CharacterMapper` to transform input text
/// * `validInputPattern` is a `Pattern` used to validate the input
///
public class DisplayColumn {
private final String title;
private final ColumnAlignment alignment;
private final Set<DisplayMode> modes;
private final ColumnDataType columnDataType;
private final Function<FileEntry,Object> getValueFn;
private final Function<Object,String> formatFn;
// These are all editable attributes
private final BiConsumer<FileEntry,String> setValueFn;
private final boolean editable;
private final boolean editInLine;
private final int maxLength;
private final List<ColumnOption> options;
private final CharacterMapper validCharacters;
private final Pattern validInputPattern;
private DisplayColumn(String title, ColumnAlignment alignment, Set<DisplayMode> modes, ColumnDataType columnDataType,
Function<FileEntry,Object> getValueFn, Function<Object,String> formatFn, BiConsumer<FileEntry,String> setValueFn,
boolean editInLine, int maxLength, List<ColumnOption> options,
CharacterMapper validCharacters, Pattern validInputPattern) {
Objects.requireNonNull(title);
Objects.requireNonNull(alignment);
Objects.requireNonNull(modes);
Objects.requireNonNull(columnDataType);
Objects.requireNonNull(getValueFn);
// setValueFn is null when not editable
Objects.requireNonNull(formatFn);
Objects.requireNonNull(options);
Objects.requireNonNull(validCharacters);
// validInputPattern is null when not used
this.title = title;
this.alignment = alignment;
this.modes = modes;
this.columnDataType = columnDataType;
this.getValueFn = getValueFn;
this.formatFn = formatFn;
this.editable = setValueFn != null || editInLine || maxLength > 0 || !options.isEmpty() || validInputPattern != null;
this.setValueFn = setValueFn;
this.editInLine = editInLine;
this.maxLength = maxLength;
this.options = Collections.unmodifiableList(options);
this.validCharacters = validCharacters;
this.validInputPattern = validInputPattern;
}
/// The column title.
public String getTitle() {
return title;
}
/// Retrieve the column alignment.
public ColumnAlignment getAlignment() {
return alignment;
}
/// Indicates if this DisplayColumn supports the DisplayMode. In this manner, the columns
/// can be filtered out. Or it can be ignored to display everything.
public boolean supports(DisplayMode mode) {
return modes.contains(mode);
}
/// Indicates the (generic) data type of this column.
public ColumnDataType getDataType() {
return columnDataType;
}
/// Get the column value for this FileEntry.
public Object getValue(FileEntry fileEntry) {
return getValueFn.apply(fileEntry);
}
/// Convert the column into a formatted string for display.
public String getDisplayText(FileEntry fileEntry) {
return formatObject(getValue(fileEntry));
}
/// Explicitly format the object. This, for instance, is used by JavaFX when it is managing the data types.
public String formatObject(Object object) {
return formatFn.apply(object);
}
public void setValue(FileEntry fileEntry, String value) {
setValueFn.accept(fileEntry, value);
}
/// Indicates if this field is editable
public boolean isEditable() {
return editable;
}
/// Indicates this column can be edited directly in the file viewer. Typically just the name.
public boolean canEditInLine() {
return editInLine;
}
/// Indicates the maximum length for the input. 0 means not applicable.
public int getMaxLength() {
return maxLength;
}
/// Get list of options (if any) when editing.
public List<ColumnOption> getOptions() {
return options;
}
/// Get the CharacterMapper that maps the input string into the valid set of characters.
public CharacterMapper getValidCharacters() {
return validCharacters;
}
/// Get the valid input regex. If input does not match this regex, it will be ignored.
/// The intent is to be able to limit to numbers, or the valid filename characters.
public Pattern getValidInputPattern() {
return validInputPattern;
}
public static <T extends FileEntry> Builder<T> builder(Class<T> fileEntryType) {
return new Builder<>(fileEntryType);
}
public static class Builder<T extends FileEntry> {
private final Class<T> fileEntryType;
private final List<DisplayColumn> columns = new ArrayList<>();
private Builder(Class<T> fileEntryType) {
Objects.requireNonNull(fileEntryType);
this.fileEntryType = fileEntryType;
}
public ColumnBuilder<T> left(String title) {
Objects.requireNonNull(title);
return new ColumnBuilder<>(this, ColumnAlignment.LEFT, title);
}
public ColumnBuilder<T> center(String title) {
Objects.requireNonNull(title);
return new ColumnBuilder<>(this, ColumnAlignment.CENTER, title);
}
public ColumnBuilder<T> right(String title) {
Objects.requireNonNull(title);
return new ColumnBuilder<>(this, ColumnAlignment.RIGHT, title);
}
public List<DisplayColumn> toList() {
return columns;
}
}
public static class ColumnBuilder<T extends FileEntry> {
public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm:ss");
public static final Pattern VALID_HEX_NUMBER = Pattern.compile("^(?i)0x[0-9a-f]+|[$][0-9a-f]+|[0-9a-f]+H?$");
private final Builder<T> builder;
private final ColumnAlignment alignment;
private final String title;
private ColumnDataType columnDataType;
private Function<T, ?> getValueFn;
private BiConsumer<FileEntry,String> setValueFn;
private Function<Object,String> formatFn;
private DisplayMode[] modes;
private boolean editInLine = false;
private int maxLength = 0;
private final List<ColumnOption> options = new ArrayList<>();
private CharacterMapper validCharacters;
private Pattern validInputPattern;
private ColumnBuilder(Builder<T> builder, ColumnAlignment alignment, String title) {
Objects.requireNonNull(builder);
Objects.requireNonNull(alignment);
Objects.requireNonNull(title);
this.builder = builder;
this.alignment = alignment;
this.title = title;
}
public ColumnBuilder<T> string(Function<T,String> getValueFn) {
Objects.requireNonNull(getValueFn);
this.columnDataType = ColumnDataType.STRING;
this.getValueFn = getValueFn;
this.formatFn = s -> String.format("%s", s);
return this;
}
public ColumnBuilder<T> string(Function<T,String> getValueFn, BiConsumer<T,String> setValueFn) {
Objects.requireNonNull(getValueFn);
Objects.requireNonNull(setValueFn);
this.setValueFn = (e,t) -> setValueFn.accept(builder.fileEntryType.cast(e), t);
return string(getValueFn);
}
public ColumnBuilder<T> integer(Function<T,Number> getValueFn, String fmt) {
Objects.requireNonNull(getValueFn);
Objects.requireNonNull(fmt);
this.columnDataType = ColumnDataType.INTEGER;
this.getValueFn = getValueFn;
this.formatFn = n -> String.format(fmt, n);
return this;
}
public ColumnBuilder<T> integer(Function<T,Number> getValueFn, String fmt, BiConsumer<T,Integer> setValueFn) {
Objects.requireNonNull(getValueFn);
Objects.requireNonNull(fmt);
Objects.requireNonNull(setValueFn);
this.setValueFn = (e,t) -> setInteger(e, t, setValueFn);
return integer(getValueFn, fmt);
}
public ColumnBuilder<T> real(Function<T,Number> getValueFn, String fmt) {
Objects.requireNonNull(getValueFn);
Objects.requireNonNull(fmt);
this.columnDataType = ColumnDataType.REAL;
this.getValueFn = getValueFn;
this.formatFn = n -> String.format(fmt, n);
return this;
}
public ColumnBuilder<T> date(Function<T,Date> getValueFn) {
Objects.requireNonNull(getValueFn);
this.columnDataType = ColumnDataType.STRING;
this.getValueFn = getValueFn;
this.formatFn = this::formatDate;
return this;
}
public ColumnBuilder<T> fileTime(Function<T, FileTime> getValueFn) {
Objects.requireNonNull(getValueFn);
this.columnDataType = ColumnDataType.STRING;
this.getValueFn = getValueFn;
this.formatFn = this::formatDate;
return this;
}
public ColumnBuilder<T> percent(Function<T, Number> numeratorFn, Function<T, Number> denominatorFn, String fmt) {
Objects.requireNonNull(numeratorFn);
Objects.requireNonNull(denominatorFn);
Objects.requireNonNull(fmt);
this.columnDataType = ColumnDataType.REAL;
this.getValueFn = t -> percentage(t, numeratorFn, denominatorFn);
this.formatFn = d -> String.format(fmt, d);
return this;
}
public ColumnBuilder<T> mode(DisplayMode... modes) {
this.modes = modes;
return this;
}
public ColumnBuilder<T> editInLine() {
this.editInLine = true;
return this;
}
public ColumnBuilder<T> maxLength(int maxLength) {
this.maxLength = maxLength;
return this;
}
public ColumnBuilder<T> option(String value, String description) {
Objects.requireNonNull(value);
Objects.requireNonNull(description);
this.options.add(new ColumnOption(value, description));
return this;
}
public ColumnBuilder<T> restrictToHexValues() {
this.validCharacters = CharacterMapper.builder()
.mapRange('a', 'f', false)
.mapRange('A', 'F', false)
.mapRange('0', '9', false)
.map('$', '$') // $1234 format
.map('x', 'x') // 0x1234 format
.map('X', 'X') // 0X1234 format
.map('h', 'h') // 1234h format
.map('H', 'H') // 1234H format
.build();
this.validInputPattern = VALID_HEX_NUMBER;
return this;
}
public Builder<T> done() {
// A helper that allows a short-cut of 0 modes meaning ALL modes.
if (modes == null || modes.length == 0) {
modes = new DisplayMode[] { DisplayMode.NATIVE, DisplayMode.DETAIL };
}
// Give a default map everything character mapper
if (validCharacters == null) {
validCharacters = CharacterMapper.builder().build();
}
// This is where generics get sticky. Either the entirety of the API is fully generic, making a bunch
// of awkward usages with FileStore<?> etc. Or we do magic stuff like this to keep is simple.
Function<FileEntry, Object> convertedFn = f -> getValueFn.apply(builder.fileEntryType.cast(f));
builder.columns.add(new DisplayColumn(title, alignment, Set.of(modes), columnDataType, convertedFn, formatFn,
setValueFn, editInLine, maxLength, options,
validCharacters, validInputPattern));
return builder;
}
/// This is a helper function to format date objects.
private String formatDate(Object value) {
if (value == null) {
return "- No Date -";
}
return switch (value) {
case Date date -> {
LocalDateTime localDateTime = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
yield localDateTime.format(DATE_TIME_FORMATTER);
}
case FileTime fileTime -> {
LocalDateTime localDateTime = fileTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
yield localDateTime.format(DATE_TIME_FORMATTER);
}
default -> "(Unknown Date)";
};
}
/// This is a helper function to help compute percentages.
private double percentage(FileEntry fileEntry, Function<T, Number> numeratorFn, Function<T, Number> denominatorFn) {
T typedFileEntry = builder.fileEntryType.cast(fileEntry);
Number numerator = numeratorFn.apply(typedFileEntry);
Number denominator = denominatorFn.apply(typedFileEntry);
if (denominator == null || denominator.doubleValue() == 0) return 0;
return (1.0 - numerator.doubleValue() / denominator.doubleValue()) * 100.0;
}
private void setInteger(FileEntry fileEntry, String text, BiConsumer<T,Integer> setter) {
T typedFileEntry = builder.fileEntryType.cast(fileEntry);
int value = 0;
if (text.startsWith("0x") || text.startsWith("0X")) {
value = Integer.parseInt(text.substring(2), 16);
}
else if (text.startsWith("$")) {
value = Integer.parseInt(text.substring(1), 16);
}
else if (text.endsWith("h") || text.endsWith("H")) {
value = Integer.parseInt(text.substring(0, text.length()-1), 16);
}
else {
try {
// We may not be able to distinguish base 10 from base 16. Try the more restrictive one first!
value = Integer.parseInt(text);
} catch (NumberFormatException e) {
value = Integer.parseInt(text, 16);
}
}
setter.accept(typedFileEntry, value);
}
}
}
@@ -0,0 +1,26 @@
/*
* AppleCommander - An Apple ][ image utility.
* Copyright (C) 2026 by Robert Greene and others
* robgreene at users.sourceforge.net
*
* This program 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 2 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, write to the Free Software Foundation, Inc.,
* 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package org.applecommander.ui;
/// Indicates the display mode to help filter columns to users' preference.
public enum DisplayMode {
NATIVE,
DETAIL
}
@@ -21,6 +21,8 @@ package org.applecommander.filestore;
import org.applecommander.capability.Capability;
import org.applecommander.source.Source;
import org.applecommander.ui.DisplayColumn;
import org.applecommander.ui.DisplayMode;
import org.applecommander.usage.BlockUsage;
import org.applecommander.usage.DiskUsage;
import org.applecommander.usage.DiskUsage.UsageType;
@@ -57,13 +59,13 @@ public abstract class FileStoreTestHelper {
public static void showFiles(Directory parent, String indent) {
FileStore fileStore = parent.getFileStore();
List<DisplayColumn> displayColumns = fileStore.getDisplayColumns().stream()
.filter(entry -> entry.supports(DisplayColumn.Mode.DETAIL))
.filter(entry -> entry.supports(DisplayMode.DETAIL))
.toList();
if (parent.getParent().isEmpty()) {
// Print columns only at the top
System.out.print(indent);
displayColumns.forEach(column -> {
System.out.print(column.headerText());
System.out.print(column.getTitle());
System.out.print(" ");
});
System.out.println();
@@ -72,7 +74,7 @@ public abstract class FileStoreTestHelper {
if (!file.isDeleted()) {
System.out.print(indent);
displayColumns.forEach(column -> {
System.out.print(column.formatAsText(file));
System.out.print(column.formatObject(file));
System.out.print(" ");
});
System.out.println();
@@ -0,0 +1,50 @@
/*
* AppleCommander - An Apple ][ image utility.
* Copyright (C) 2026 by Robert Greene and others
* robgreene at users.sourceforge.net
*
* This program 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 2 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, write to the Free Software Foundation, Inc.,
* 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package org.applecommander.ui;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class CharacterMapperTest {
@Test
public void testForcedUppercase() {
CharacterMapper hex = CharacterMapper.builder()
.mapRange('a', 'f', true)
.mapRange('A', 'F', false)
.mapRange('0', '9', false)
.map('$', '$')
.build();
assertEquals("$12AF", hex.map(">>> $12af <<<"));
}
@Test
public void testMixedCase() {
CharacterMapper hex = CharacterMapper.builder()
.mapRange('a', 'f', false)
.mapRange('A', 'F', false)
.mapRange('0', '9', false)
.map('$', '$')
.build();
assertEquals("$12af", hex.map(">>> $12af <<<"));
}
}
@@ -0,0 +1,38 @@
/*
* AppleCommander - An Apple ][ image utility.
* Copyright (C) 2026 by Robert Greene and others
* robgreene at users.sourceforge.net
*
* This program 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 2 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, write to the Free Software Foundation, Inc.,
* 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package org.applecommander.ui;
import org.junit.jupiter.api.Test;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.*;
public class DisplayColumnTest {
@Test
public void testHexPattern() {
Pattern pattern = DisplayColumn.ColumnBuilder.VALID_HEX_NUMBER;
assertNotNull(pattern);
assertTrue(pattern.matcher("$12ab").matches());
assertTrue(pattern.matcher("0x12AB").matches());
assertTrue(pattern.matcher("12AbH").matches());
assertFalse(pattern.matcher("he110").matches());
}
}