diff --git a/odradek-app/src/main/java/module-info.java b/odradek-app/src/main/java/module-info.java index c2ebacc7..c97397de 100644 --- a/odradek-app/src/main/java/module-info.java +++ b/odradek-app/src/main/java/module-info.java @@ -43,6 +43,7 @@ provides sh.adelessfox.odradek.ui.actions.Action with sh.adelessfox.odradek.app.ui.component.bookmarks.menu.ToggleBookmarkAction, sh.adelessfox.odradek.app.ui.component.bookmarks.menu.RenameBookmarkAction, + sh.adelessfox.odradek.app.ui.component.usages.menu.ShowUsagesAction, sh.adelessfox.odradek.app.ui.menu.main.MainMenu.File, sh.adelessfox.odradek.app.ui.menu.main.MainMenu.Edit, sh.adelessfox.odradek.app.ui.menu.main.MainMenu.Help, diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/Application.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/Application.java index bfc6aa42..b24c7219 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/Application.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/Application.java @@ -10,14 +10,17 @@ import sh.adelessfox.odradek.app.ui.menu.main.MainMenu; import sh.adelessfox.odradek.app.ui.settings.Settings; import sh.adelessfox.odradek.app.ui.settings.SettingsEvent; +import sh.adelessfox.odradek.event.EventBus; import sh.adelessfox.odradek.game.Game; import sh.adelessfox.odradek.game.hfw.game.ForbiddenWestGame; import sh.adelessfox.odradek.ui.actions.Actions; import sh.adelessfox.odradek.ui.data.DataContext; import sh.adelessfox.odradek.ui.editors.EditorManager; +import sh.adelessfox.odradek.util.OS; import javax.swing.*; import java.io.IOException; +import java.nio.file.Path; public final class Application { private static final Logger log = LoggerFactory.getLogger(Application.class); @@ -44,8 +47,10 @@ public static synchronized void start(ApplicationParameters params) throws IOExc } var game = (ForbiddenWestGame) Game.load(params.sourcePath()); + var config = determineConfigPath("Odradek"); var component = DaggerApplicationComponent.builder() .game(game) + .config(config) .build(); application = new Application(component, params); @@ -114,6 +119,18 @@ private static void saveFrameSettings(Settings settings, JFrame frame) { )); } + private static Path determineConfigPath(String identifier) { + String userHome = System.getProperty("user.home"); + if (userHome == null) { + throw new IllegalStateException("Unable to determine user home directory"); + } + return switch (OS.name()) { + case WINDOWS -> Path.of(userHome, "AppData", "Local", identifier); + case MACOS -> Path.of(userHome, "Library", "Application Support", identifier); + case LINUX -> Path.of(userHome, ".config", identifier); + }; + } + public ForbiddenWestGame game() { return component.game(); } @@ -126,6 +143,10 @@ public Bookmarks bookmarks() { return component.bookmarks(); } + public EventBus events() { + return component.events(); + } + public boolean isDebugMode() { return parameters.enableDebugMode(); } diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/ApplicationComponent.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/ApplicationComponent.java index 9a204ea1..440c1d45 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/ApplicationComponent.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/ApplicationComponent.java @@ -2,6 +2,7 @@ import dagger.BindsInstance; import dagger.Component; +import jakarta.inject.Named; import jakarta.inject.Singleton; import sh.adelessfox.odradek.app.ui.bookmarks.Bookmarks; import sh.adelessfox.odradek.app.ui.component.main.MainPresenter; @@ -11,6 +12,8 @@ import sh.adelessfox.odradek.game.hfw.game.ForbiddenWestGame; import sh.adelessfox.odradek.ui.editors.EditorManager; +import java.nio.file.Path; + @Singleton @Component(modules = {ApplicationModule.class, SettingsModule.class}) interface ApplicationComponent { @@ -22,15 +25,18 @@ interface ApplicationComponent { Bookmarks bookmarks(); - ForbiddenWestGame game(); - EventBus events(); + ForbiddenWestGame game(); + @Component.Builder interface Builder { @BindsInstance Builder game(ForbiddenWestGame game); + @BindsInstance + Builder config(@Named("config") Path config); + @SuppressWarnings("ClassEscapesDefinedScope") ApplicationComponent build(); } diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/bookmarks/Bookmarks.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/bookmarks/Bookmarks.java index f944007e..f5ed1f02 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/bookmarks/Bookmarks.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/bookmarks/Bookmarks.java @@ -19,7 +19,7 @@ public final class Bookmarks { private final Map bookmarks = new LinkedHashMap<>(); @Inject - public Bookmarks(EventBus eventBus) { + Bookmarks(EventBus eventBus) { this.eventBus = eventBus; } diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/bookmarks/BookmarkToolPanel.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/bookmarks/BookmarkToolPanel.java index 26d3095f..08f267ac 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/bookmarks/BookmarkToolPanel.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/bookmarks/BookmarkToolPanel.java @@ -1,6 +1,7 @@ package sh.adelessfox.odradek.app.ui.component.bookmarks; import jakarta.inject.Inject; +import jakarta.inject.Singleton; import sh.adelessfox.odradek.app.ui.Application; import sh.adelessfox.odradek.app.ui.bookmarks.Bookmark; import sh.adelessfox.odradek.app.ui.bookmarks.BookmarkEvent; @@ -17,6 +18,7 @@ import javax.swing.*; +@Singleton public class BookmarkToolPanel implements ToolPanel { private final Bookmarks repository; private final EventBus eventBus; @@ -24,7 +26,7 @@ public class BookmarkToolPanel implements ToolPanel { private StructuredTree tree; @Inject - public BookmarkToolPanel(Bookmarks repository, EventBus eventBus) { + BookmarkToolPanel(Bookmarks repository, EventBus eventBus) { this.repository = repository; this.eventBus = eventBus; @@ -41,6 +43,7 @@ public JComponent createComponent() { tree = new StructuredTree<>(new BookmarkStructure.Root(repository)); tree.setShowsRootHandles(true); tree.setLabelProvider(new BookmarkLabelProvider()); + tree.setPlaceholderText("No bookmarks\n\nRight-click on an object to bookmark it"); tree.addActionListener(TreeActionListener.treePathClickedAdapter(event -> { var component = event.getLastPathComponent(); if (component instanceof BookmarkStructure.Bookmark bookmark) { diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphPresenter.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphPresenter.java index 5d3e7a02..4001e509 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphPresenter.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphPresenter.java @@ -19,7 +19,7 @@ public class GraphPresenter implements Presenter { private final GraphView view; @Inject - public GraphPresenter( + GraphPresenter( GraphView view, EventBus eventBus ) { diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphStructure.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphStructure.java index 77a5ad7d..6abb98aa 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphStructure.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphStructure.java @@ -1,9 +1,9 @@ package sh.adelessfox.odradek.app.ui.component.graph; import sh.adelessfox.odradek.game.Game; -import sh.adelessfox.odradek.game.ObjectHolder; import sh.adelessfox.odradek.game.ObjectId; import sh.adelessfox.odradek.game.ObjectIdHolder; +import sh.adelessfox.odradek.game.ObjectSupplier; import sh.adelessfox.odradek.game.hfw.rtti.HorizonForbiddenWest.StreamingGroupData; import sh.adelessfox.odradek.game.hfw.storage.StreamingGraphResource; import sh.adelessfox.odradek.rtti.ClassTypeInfo; @@ -358,7 +358,7 @@ record GroupObject( StreamingGraphResource graph, StreamingGroupData group, int index - ) implements GraphStructure, ObjectHolder, ObjectIdHolder { + ) implements GraphStructure, ObjectSupplier, ObjectIdHolder { @Override public TypedObject readObject(Game game) throws IOException { return game.readObject(group.groupID(), index); @@ -369,11 +369,6 @@ public ClassTypeInfo objectType() { return graph.types().get(group.typeStart() + index); } - @Override - public String objectName() { - return "%s_%s_%s".formatted(objectType().name(), group.groupID(), index); - } - @Override public ObjectId objectId() { return new ObjectId(group.groupID(), index); diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphView.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphView.java index 8c3d953d..33a39f5a 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphView.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphView.java @@ -6,9 +6,11 @@ import org.slf4j.LoggerFactory; import sh.adelessfox.odradek.app.ui.component.PreviewManager; import sh.adelessfox.odradek.app.ui.component.common.View; +import sh.adelessfox.odradek.app.ui.component.main.MainEvent; import sh.adelessfox.odradek.app.ui.menu.graph.GraphMenu; import sh.adelessfox.odradek.event.EventBus; -import sh.adelessfox.odradek.game.ObjectHolder; +import sh.adelessfox.odradek.game.ObjectId; +import sh.adelessfox.odradek.game.ObjectSupplier; import sh.adelessfox.odradek.game.hfw.game.ForbiddenWestGame; import sh.adelessfox.odradek.rtti.TypeInfo; import sh.adelessfox.odradek.rtti.data.TypedObject; @@ -40,7 +42,7 @@ public class GraphView implements View, ToolPanel { private final ValidationPopup filterValidationPopup; @Inject - public GraphView(EventBus eventBus, ForbiddenWestGame game) { + GraphView(EventBus eventBus, ForbiddenWestGame game) { this.eventBus = eventBus; this.game = game; @@ -58,7 +60,8 @@ public void actionPerformed(ActionEvent e) { tree = createGraphTree(); var treeScrollPane = new JScrollPane(tree); - treeScrollPane.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(KeyStroke.getKeyStroke("ctrl F"), "focus-in"); + treeScrollPane.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT) + .put(KeyStroke.getKeyStroke("ctrl F"), "focus-in"); treeScrollPane.getActionMap().put("focus-in", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { @@ -183,9 +186,9 @@ public Optional getIcon(GraphStructure element) { tree.addActionListener(TreeActionListener.treePathClickedAdapter(event -> { var component = event.getLastPathComponent(); if (component instanceof GraphStructure.GroupObject groupObject) { - eventBus.publish(new GraphViewEvent.ShowObject( + eventBus.publish(new MainEvent.ShowObject(new ObjectId( groupObject.group().groupID(), - groupObject.index() + groupObject.index()) )); } })); @@ -193,7 +196,7 @@ public Optional getIcon(GraphStructure element) { PreviewManager.install(tree, game, new PreviewManager.PreviewObjectProvider() { @Override public Optional getObject(JTree tree, Object value) { - var holder = (ObjectHolder) value; + var holder = (ObjectSupplier) value; try { return Optional.of(holder.readObject(game)); } catch (IOException e) { @@ -204,7 +207,7 @@ public Optional getObject(JTree tree, Object value) { @Override public Optional getType(JTree tree, Object value) { - if (value instanceof ObjectHolder provider) { + if (value instanceof ObjectSupplier provider) { return Optional.of(provider.objectType()); } return Optional.empty(); diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphViewEvent.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphViewEvent.java index 2330e0de..68e1211a 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphViewEvent.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/graph/GraphViewEvent.java @@ -3,7 +3,6 @@ import sh.adelessfox.odradek.event.Event; public sealed interface GraphViewEvent extends Event { - record UpdateFilter(String query, boolean matchCase, boolean matchWholeWord) implements GraphViewEvent {} - - record ShowObject(int groupId, int objectIndex) implements GraphViewEvent {} + record UpdateFilter(String query, boolean matchCase, boolean matchWholeWord) implements GraphViewEvent { + } } diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/main/MainEvent.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/main/MainEvent.java new file mode 100644 index 00000000..f14792dc --- /dev/null +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/main/MainEvent.java @@ -0,0 +1,15 @@ +package sh.adelessfox.odradek.app.ui.component.main; + +import sh.adelessfox.odradek.event.Event; +import sh.adelessfox.odradek.game.ObjectId; + +public sealed interface MainEvent extends Event { + record ShowPanel(String id) implements MainEvent { + } + + record ShowObject(ObjectId objectId) implements MainEvent { + } + + record ShowLinks(ObjectId objectId) implements MainEvent { + } +} diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/main/MainPresenter.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/main/MainPresenter.java index 604010c0..22b25fa2 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/main/MainPresenter.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/main/MainPresenter.java @@ -3,13 +3,12 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; import sh.adelessfox.odradek.app.ui.component.common.Presenter; -import sh.adelessfox.odradek.app.ui.component.graph.GraphViewEvent; -import sh.adelessfox.odradek.app.ui.editors.ObjectEditorInput; import sh.adelessfox.odradek.app.ui.editors.ObjectEditorInputLazy; import sh.adelessfox.odradek.app.ui.settings.Settings; import sh.adelessfox.odradek.app.ui.settings.SettingsEvent; import sh.adelessfox.odradek.event.EventBus; import sh.adelessfox.odradek.game.ObjectId; +import sh.adelessfox.odradek.game.ObjectIdHolder; import sh.adelessfox.odradek.ui.editors.Editor; import sh.adelessfox.odradek.ui.editors.EditorManager; import sh.adelessfox.odradek.ui.editors.stack.EditorStackContainer; @@ -22,7 +21,7 @@ public class MainPresenter implements Presenter { private final EditorManager editorManager; @Inject - public MainPresenter( + MainPresenter( EditorManager editorManager, MainView view, EventBus eventBus @@ -30,7 +29,7 @@ public MainPresenter( this.view = view; this.editorManager = editorManager; - eventBus.subscribe(GraphViewEvent.ShowObject.class, event -> openObject(event.groupId(), event.objectIndex())); + eventBus.subscribe(MainEvent.ShowObject.class, event -> openObject(event.objectId())); eventBus.subscribe(SettingsEvent.class, event -> { switch (event) { case SettingsEvent.AfterLoad(var settings) -> loadEditors(settings); @@ -44,8 +43,8 @@ public MainView getView() { return view; } - private void openObject(int groupId, int objectIndex) { - editorManager.openEditor(new ObjectEditorInputLazy(groupId, objectIndex)); + private void openObject(ObjectId objectId) { + editorManager.openEditor(new ObjectEditorInputLazy(objectId)); } private void loadEditors(Settings settings) { @@ -95,10 +94,8 @@ private Settings.EditorState saveContainer(EditorStackContainer container) { if (editor == selected) { selection = objects.size(); } - switch (editor.getInput()) { - case ObjectEditorInput i -> objects.add(new ObjectId(i.groupId(), i.objectIndex())); - case ObjectEditorInputLazy i -> objects.add(new ObjectId(i.groupId(), i.objectIndex())); - default -> { /* do nothing*/ } + if (editor.getInput() instanceof ObjectIdHolder holder) { + objects.add(holder.objectId()); } } diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/main/MainView.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/main/MainView.java index f63916bd..d650a30d 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/main/MainView.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/main/MainView.java @@ -5,6 +5,8 @@ import sh.adelessfox.odradek.app.ui.component.bookmarks.BookmarkToolPanel; import sh.adelessfox.odradek.app.ui.component.common.View; import sh.adelessfox.odradek.app.ui.component.graph.GraphPresenter; +import sh.adelessfox.odradek.app.ui.component.usages.UsagesToolPanel; +import sh.adelessfox.odradek.event.EventBus; import sh.adelessfox.odradek.ui.components.tool.ToolPanelContainer; import sh.adelessfox.odradek.ui.editors.EditorManager; import sh.adelessfox.odradek.ui.util.Fugue; @@ -13,19 +15,28 @@ @Singleton public class MainView implements View { + public static final String GRAPH_PANEL_ID = "graph"; + public static final String BOOKMARKS_PANEL_ID = "bookmarks"; + public static final String USAGES_PANEL_ID = "usages"; + private final ToolPanelContainer root; @Inject - public MainView( + MainView( GraphPresenter graphPresenter, BookmarkToolPanel bookmarkPanel, - EditorManager editorManager + UsagesToolPanel usagesPanel, + EditorManager editorManager, + EventBus eventBus ) { root = new ToolPanelContainer(ToolPanelContainer.Placement.LEFT); - root.addPrimaryPanel("Graph", Fugue.getIcon("blue-document"), graphPresenter.getView()); - root.addSecondaryPanel("Bookmarks", Fugue.getIcon("blue-document-bookmark"), bookmarkPanel); + root.addPrimaryPanel(GRAPH_PANEL_ID, "Graph", Fugue.getIcon("blue-document"), graphPresenter.getView()); + root.addSecondaryPanel(BOOKMARKS_PANEL_ID, "Bookmarks", Fugue.getIcon("blue-document-bookmark"), bookmarkPanel); + root.addSecondaryPanel(USAGES_PANEL_ID, "Usages", Fugue.getIcon("magnifier-left"), usagesPanel); root.setContent(editorManager.getRoot()); - root.showPanel(graphPresenter.getView()); + root.showPanel(GRAPH_PANEL_ID); + + eventBus.subscribe(MainEvent.ShowPanel.class, event -> root.showPanel(event.id())); } @Override diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/usages/UsagesLabelProvider.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/usages/UsagesLabelProvider.java new file mode 100644 index 00000000..033c2556 --- /dev/null +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/usages/UsagesLabelProvider.java @@ -0,0 +1,71 @@ +package sh.adelessfox.odradek.app.ui.component.usages; + +import sh.adelessfox.odradek.game.hfw.game.ForbiddenWestGame; +import sh.adelessfox.odradek.rtti.ClassTypeInfo; +import sh.adelessfox.odradek.ui.components.StyledFragment; +import sh.adelessfox.odradek.ui.components.StyledText; +import sh.adelessfox.odradek.ui.components.tree.StyledTreeLabelProvider; +import sh.adelessfox.odradek.ui.util.Fugue; + +import javax.swing.*; +import java.util.Optional; + +final class UsagesLabelProvider implements StyledTreeLabelProvider { + private final ForbiddenWestGame game; + + UsagesLabelProvider(ForbiddenWestGame game) { + this.game = game; + } + + @Override + public Optional getStyledText(UsagesStructure element) { + return switch (element) { + case UsagesStructure.Root _ -> Optional.empty(); + case UsagesStructure.Objects _ -> StyledText.builder() + .add("Object", StyledFragment.BOLD) + .build(); + case UsagesStructure.Object(var object) -> StyledText.builder() + .add("[" + object.toString() + "]") + .add(" " + getType(object.groupId(), object.objectIndex()), StyledFragment.NAME) + .build(); + case UsagesStructure.Links(var type, var links) -> StyledText.builder() + .add(getText(type), StyledFragment.BOLD) + .add(" " + (links.size() == 1 ? "1 result" : links.size() + " results"), StyledFragment.GRAYED) + .build(); + case UsagesStructure.Link(_, var link) -> StyledText.builder() + .add("[" + link.groupId() + ":" + link.objectIndex() + "]") + .add(" " + getType(link.groupId(), link.objectIndex()), StyledFragment.NAME) + .add(" " + link.path(), StyledFragment.GRAYED) + .build(); + }; + } + + @Override + public Optional getIcon(UsagesStructure element) { + return switch (element) { + case UsagesStructure.Object _ -> Optional.of(Fugue.getIcon("blue-document")); + case UsagesStructure.Link(var type, _) -> Optional.of(getIcon(type)); + default -> Optional.empty(); + }; + } + + private static String getText(UsagesStructure.Type type) { + return switch (type) { + case INCOMING -> "Incoming links"; + case OUTGOING -> "Outgoing links"; + }; + } + + private static Icon getIcon(UsagesStructure.Type type) { + return switch (type) { + case INCOMING -> Fugue.getIcon("blue-document-import"); + case OUTGOING -> Fugue.getIcon("blue-document-export"); + }; + } + + private ClassTypeInfo getType(int groupId, int objectIndex) { + var graph = game.getStreamingGraph(); + var group = graph.group(groupId); + return graph.types().get(group.typeStart() + objectIndex); + } +} diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/usages/UsagesStructure.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/usages/UsagesStructure.java new file mode 100644 index 00000000..788ebdd2 --- /dev/null +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/usages/UsagesStructure.java @@ -0,0 +1,97 @@ +package sh.adelessfox.odradek.app.ui.component.usages; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sh.adelessfox.odradek.game.LinkProvider; +import sh.adelessfox.odradek.game.ObjectId; +import sh.adelessfox.odradek.game.ObjectIdHolder; +import sh.adelessfox.odradek.ui.components.tree.TreeStructure; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +sealed interface UsagesStructure extends TreeStructure { + Logger log = LoggerFactory.getLogger(UsagesStructure.class); + + @Override + default List getChildren() { + return switch (this) { + case Root(var provider, var object) -> { + var nodes = new ArrayList(); + nodes.add(new Objects(object)); + try { + nodes.add(new Links(Type.INCOMING, provider.getIncomingLinks(object))); + } catch (IOException e) { + log.error("Failed to get incoming links for object {}", object, e); + } + try { + nodes.add(new Links(Type.OUTGOING, provider.getOutgoingLinks(object))); + } catch (IOException e) { + log.error("Failed to get outgoing links for object {}", object, e); + } + yield nodes.stream() + .filter(n -> !(n instanceof Links links && links.links().isEmpty())) + .toList(); + } + case Objects(var object) -> List.of(new Object(object)); + case Object _ -> List.of(); + case Links(var type, var links) -> links.stream() + .map(link -> new Link(type, link)) + .toList(); + case Link _ -> List.of(); + }; + } + + @Override + default boolean hasChildren() { + return switch (this) { + case Root _, Objects _ -> true; + case Links(_, var links) -> !links.isEmpty(); + case Link _, Object _ -> false; + }; + } + + enum Type { + INCOMING, + OUTGOING + } + + record Root(LinkProvider provider, ObjectId object) implements UsagesStructure { + } + + record Objects(ObjectId object) implements UsagesStructure { + } + + record Object(ObjectId objectId) implements UsagesStructure, ObjectIdHolder { + } + + record Links(Type type, List links) implements UsagesStructure { + @Override + public boolean equals(java.lang.Object obj) { + return obj instanceof Links(var type1, _) && type == type1; + } + + @Override + public int hashCode() { + return type.hashCode(); + } + } + + record Link(Type type, LinkProvider.Link link) implements UsagesStructure, ObjectIdHolder { + @Override + public ObjectId objectId() { + return new ObjectId(link.groupId(), link.objectIndex()); + } + + @Override + public boolean equals(java.lang.Object obj) { + return obj instanceof Link(Type type1, LinkProvider.Link link1) && type == type1 && link.equals(link1); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(type, link); + } + } +} diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/usages/UsagesToolPanel.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/usages/UsagesToolPanel.java new file mode 100644 index 00000000..b7d671f5 --- /dev/null +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/usages/UsagesToolPanel.java @@ -0,0 +1,304 @@ +package sh.adelessfox.odradek.app.ui.component.usages; + +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.inject.Singleton; +import net.miginfocom.swing.MigLayout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sh.adelessfox.odradek.app.ui.Application; +import sh.adelessfox.odradek.app.ui.component.main.MainEvent; +import sh.adelessfox.odradek.app.ui.editors.ObjectEditorInputLazy; +import sh.adelessfox.odradek.app.ui.menu.graph.GraphMenu; +import sh.adelessfox.odradek.event.DefaultEventBus; +import sh.adelessfox.odradek.event.Event; +import sh.adelessfox.odradek.event.EventBus; +import sh.adelessfox.odradek.game.ObjectId; +import sh.adelessfox.odradek.game.ObjectIdHolder; +import sh.adelessfox.odradek.game.hfw.game.ForbiddenWestGame; +import sh.adelessfox.odradek.game.hfw.game.LinkDatabase; +import sh.adelessfox.odradek.ui.actions.Actions; +import sh.adelessfox.odradek.ui.components.tool.ToolPanel; +import sh.adelessfox.odradek.ui.components.tree.StructuredTree; +import sh.adelessfox.odradek.ui.components.tree.StructuredTreeModel; +import sh.adelessfox.odradek.ui.components.tree.TreeActionListener; +import sh.adelessfox.odradek.ui.data.DataKeys; + +import javax.swing.*; +import java.awt.*; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutionException; + +@Singleton +public final class UsagesToolPanel implements ToolPanel { + private static final Logger log = LoggerFactory.getLogger(UsagesToolPanel.class); + + private static final String CARD_SETUP = "setup"; + private static final String CARD_LOADING = "loading"; + private static final String CARD_SCANNING = "scanning"; + private static final String CARD_MAIN = "main"; + + private final ForbiddenWestGame game; + private final EventBus appEventBus; + private final Path config; + + private LinkDatabase database; + private ObjectId pendingObjectId; + + @Inject + UsagesToolPanel(ForbiddenWestGame game, EventBus appEventBus, @Named("config") Path config) { + this.game = game; + this.appEventBus = appEventBus; + this.config = config; + } + + @Override + public JComponent createComponent() { + var path = determineDatabasePath(game, config); + var eventBus = new DefaultEventBus(); + + var layout = new CardLayout(); + var panel = new JPanel(); + panel.setLayout(layout); + panel.add(createInitialView(eventBus), CARD_SETUP); + panel.add(createLoadingView(), CARD_LOADING); + panel.add(createProgressView(eventBus), CARD_SCANNING); + panel.add(createTreeView(eventBus, game), CARD_MAIN); + + // Show the setup card by default + layout.show(panel, CARD_SETUP); + + eventBus.subscribe(Events.class, event -> { + switch (event) { + case Events.ScanStart _ -> { + layout.show(panel, CARD_SCANNING); + new BuildDatabaseWorker(eventBus, game, path).execute(); + } + case Events.DatabaseReady _ -> { + layout.show(panel, CARD_LOADING); + new LoadDatabaseWorker(eventBus, game, path).execute(); + } + case Events.DatabaseLoaded e -> { + database = e.database(); + layout.show(panel, CARD_MAIN); + + if (pendingObjectId != null) { + eventBus.publish(new Events.ShowObject(pendingObjectId)); + pendingObjectId = null; + } + } + case Events.DatabaseNotLoaded _ -> { + JOptionPane.showMessageDialog( + panel, + "Unable to load the link database; refer to logs for more details", + "Links", + JOptionPane.ERROR_MESSAGE); + layout.show(panel, CARD_SETUP); + } + default -> { /* ignored */} + } + }); + + // Forward show object request to the internal event bus + appEventBus.subscribe( + MainEvent.ShowLinks.class, + event -> eventBus.publish(new Events.ShowObject(event.objectId()))); + + if (Files.exists(path)) { + // Trigger loading immediately if database file already exists + eventBus.publish(new Events.DatabaseReady()); + } + + return panel; + } + + @Override + public boolean isFocused() { + return false; + } + + @Override + public void setFocus() { + // do nothing + } + + private JComponent createInitialView(EventBus eventBus) { + JLabel text = new JLabel("Link database not found", SwingConstants.CENTER); + + JButton button = new JButton("Scan game resources"); + button.addActionListener(_ -> eventBus.publish(new Events.ScanStart())); + + JPanel panel = new JPanel(); + panel.setLayout(new MigLayout("ins panel,fill", "al center", "[fill,grow][][][fill,grow]")); + panel.add(text, "cell 0 1"); + panel.add(button, "cell 0 2"); + + return panel; + } + + private JComponent createLoadingView() { + return new JLabel("Loading link database...", SwingConstants.CENTER); + } + + private static JComponent createProgressView(EventBus eventBus) { + var progress = new JProgressBar(); + var label = new JLabel("N/A"); + + var panel = new JPanel(); + panel.setLayout(new MigLayout("ins panel,fill", "al center", "[fill,grow][][][][fill,grow]")); + panel.add(new JLabel("Scanning game resources"), "cell 0 1"); + panel.add(progress, "cell 0 2,growx"); + panel.add(label, "cell 0 3"); + + eventBus.subscribe( + Events.ScanProgress.class, + event -> { + label.setText(event.progress().toString()); + progress.setMaximum(event.progress().max()); + progress.setValue(event.progress().cur()); + }); + + return panel; + } + + private JComponent createTreeView(EventBus eventBus, ForbiddenWestGame game) { + var tree = new StructuredTree(); + tree.setLabelProvider(new UsagesLabelProvider(game)); + tree.setShowsRootHandles(true); + tree.setRootVisible(false); + tree.setPlaceholderText("No object selected\n\nRight-click on an object to inspect its usages"); + tree.addActionListener(TreeActionListener.treePathClickedAdapter(event -> { + var component = event.getLastPathComponent(); + if (component instanceof ObjectIdHolder holder) { + Application.getInstance().editors().openEditor(new ObjectEditorInputLazy(holder.objectId())); + } + })); + + Actions.installContextMenu(tree, GraphMenu.ID, key -> { + if (DataKeys.GAME.is(key)) { + return Optional.of(game); + } + return tree.get(key); + }); + + eventBus.subscribe(Events.ShowObject.class, event -> { + if (database == null) { + pendingObjectId = event.objectId(); + return; + } + tree.setModel(new StructuredTreeModel<>(new UsagesStructure.Root(database, event.objectId()))); + tree.expand(); + }); + + return new JScrollPane(tree); + } + + private Path determineDatabasePath(ForbiddenWestGame game, Path config) { + try { + return config.resolve("links-" + LinkDatabase.computeHash(game) + ".db"); + } catch (IOException e) { + log.error("Failed to compute link database hash, using fallback path", e); + return config.resolve("links.db"); + } + } + + record Progress(int cur, int max) { + @Override + public String toString() { + return "%d/%d".formatted(cur, max); + } + } + + private sealed interface Events extends Event { + record ScanStart() implements Events { + } + + record ScanProgress(Progress progress) implements Events { + } + + record DatabaseReady() implements Events { + } + + record DatabaseLoaded(LinkDatabase database) implements Events { + } + + record DatabaseNotLoaded(Throwable reason) implements Events { + } + + record ShowObject(ObjectId objectId) implements Events { + } + } + + private static class LoadDatabaseWorker extends SwingWorker { + private final EventBus eventBus; + private final ForbiddenWestGame game; + private final Path path; + + LoadDatabaseWorker(EventBus eventBus, ForbiddenWestGame game, Path path) { + this.eventBus = eventBus; + this.game = game; + this.path = path; + } + + @Override + protected LinkDatabase doInBackground() throws Exception { + log.debug("Loading link database from {}", path); + return LinkDatabase.open(game, path); + } + + @Override + protected void done() { + try { + eventBus.publish(new Events.DatabaseLoaded(get())); + } catch (InterruptedException e) { + log.debug("Link database load interrupted", e); + eventBus.publish(new Events.DatabaseNotLoaded(e)); + } catch (ExecutionException e) { + log.debug("Failed to load link database", e.getCause()); + eventBus.publish(new Events.DatabaseNotLoaded(e.getCause())); + } + } + } + + private static class BuildDatabaseWorker extends SwingWorker { + private final EventBus eventBus; + private final ForbiddenWestGame game; + private final Path path; + + BuildDatabaseWorker(EventBus eventBus, ForbiddenWestGame game, Path path) { + this.eventBus = eventBus; + this.game = game; + this.path = path; + } + + @Override + protected Void doInBackground() throws Exception { + log.debug("Building link database to {}", path); + LinkDatabase.build(game, path, (cur, max) -> publish(new Progress(cur, max))); + return null; + } + + @Override + protected void process(List chunks) { + eventBus.publish(new Events.ScanProgress(chunks.getLast())); + } + + @Override + protected void done() { + try { + get(); + eventBus.publish(new Events.DatabaseReady()); + } catch (InterruptedException e) { + log.debug("Link database build interrupted", e); + eventBus.publish(new Events.DatabaseNotLoaded(e)); + } catch (ExecutionException e) { + log.debug("Failed to build link database", e.getCause()); + eventBus.publish(new Events.DatabaseNotLoaded(e.getCause())); + } + } + } +} diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/usages/menu/ShowUsagesAction.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/usages/menu/ShowUsagesAction.java new file mode 100644 index 00000000..bd79f88c --- /dev/null +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/component/usages/menu/ShowUsagesAction.java @@ -0,0 +1,31 @@ +package sh.adelessfox.odradek.app.ui.component.usages.menu; + +import sh.adelessfox.odradek.app.ui.Application; +import sh.adelessfox.odradek.app.ui.component.bookmarks.menu.BookmarkMenu; +import sh.adelessfox.odradek.app.ui.component.main.MainEvent; +import sh.adelessfox.odradek.app.ui.component.main.MainView; +import sh.adelessfox.odradek.app.ui.menu.graph.GraphMenu; +import sh.adelessfox.odradek.game.ObjectIdHolder; +import sh.adelessfox.odradek.ui.actions.Action; +import sh.adelessfox.odradek.ui.actions.ActionContext; +import sh.adelessfox.odradek.ui.actions.ActionContribution; +import sh.adelessfox.odradek.ui.actions.ActionRegistration; +import sh.adelessfox.odradek.ui.data.DataKeys; + +@ActionRegistration(text = "Show &Usages", icon = "fugue:chain", keystroke = "alt F7") +@ActionContribution(parent = GraphMenu.ID) +@ActionContribution(parent = BookmarkMenu.ID) +public final class ShowUsagesAction extends Action { + @Override + public void perform(ActionContext context) { + var holder = context.get(DataKeys.SELECTION, ObjectIdHolder.class).orElseThrow(); + var eventBus = Application.getInstance().events(); + eventBus.publish(new MainEvent.ShowPanel(MainView.USAGES_PANEL_ID)); + eventBus.publish(new MainEvent.ShowLinks(holder.objectId())); + } + + @Override + public boolean isVisible(ActionContext context) { + return context.get(DataKeys.SELECTION, ObjectIdHolder.class).isPresent(); + } +} diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectEditor.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectEditor.java index 7b46c8e0..d239997a 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectEditor.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectEditor.java @@ -18,7 +18,7 @@ import java.util.List; import java.util.Optional; -public final class ObjectEditor implements Editor, ObjectHolder, ObjectIdHolder, DataContext { +public final class ObjectEditor implements Editor, ObjectIdHolder, ObjectSupplier, DataContext { public static final class Provider implements Editor.Provider { @Override public Editor createEditor(EditorInput input, EditorSite site) { @@ -97,18 +97,13 @@ public TypedObject readObject(Game game) { } @Override - public ClassTypeInfo objectType() { - return input.object().getType(); - } - - @Override - public String objectName() { - return "%s_%s_%s".formatted(objectType().name(), input.groupId(), input.objectIndex()); + public ObjectId objectId() { + return input.objectId(); } @Override - public ObjectId objectId() { - return new ObjectId(input.groupId(), input.objectIndex()); + public ClassTypeInfo objectType() { + return input.object().getType(); } @Override diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectEditorInput.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectEditorInput.java index caa168f7..0c6a1f96 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectEditorInput.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectEditorInput.java @@ -1,27 +1,33 @@ package sh.adelessfox.odradek.app.ui.editors; import sh.adelessfox.odradek.game.Game; +import sh.adelessfox.odradek.game.ObjectId; +import sh.adelessfox.odradek.game.ObjectIdHolder; import sh.adelessfox.odradek.rtti.data.TypedObject; import sh.adelessfox.odradek.ui.editors.EditorInput; -public record ObjectEditorInput(Game game, TypedObject object, int groupId, int objectIndex) implements EditorInput { +public record ObjectEditorInput( + Game game, + TypedObject object, + ObjectId objectId +) implements EditorInput, ObjectIdHolder { @Override public String getName() { - return "[%d:%d] %s".formatted(groupId, objectIndex, object.getType()); + return "[%s] %s".formatted(objectId, object.getType()); } @Override public String getDescription() { - return "Group: %d\nObject: %d".formatted(groupId, objectIndex); + return "Group: %d\nObject: %d".formatted(objectId.groupId(), objectId.objectIndex()); } @Override public boolean representsSameInput(EditorInput other) { if (other instanceof ObjectEditorInput o) { - return this.groupId == o.groupId && this.objectIndex == o.objectIndex; + return objectId.equals(o.objectId); } if (other instanceof ObjectEditorInputLazy o) { - return this.groupId == o.groupId() && this.objectIndex == o.objectIndex(); + return objectId.equals(o.objectId()); } return false; } diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectEditorInputLazy.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectEditorInputLazy.java index 94982123..8896f574 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectEditorInputLazy.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectEditorInputLazy.java @@ -2,51 +2,47 @@ import sh.adelessfox.odradek.app.ui.Application; import sh.adelessfox.odradek.game.ObjectId; +import sh.adelessfox.odradek.game.ObjectIdHolder; import sh.adelessfox.odradek.ui.editors.EditorInput; import sh.adelessfox.odradek.ui.editors.lazy.LazyEditorInput; public record ObjectEditorInputLazy( - int groupId, - int objectIndex, + ObjectId objectId, boolean canLoadImmediately -) implements LazyEditorInput { +) implements LazyEditorInput, ObjectIdHolder { public ObjectEditorInputLazy(ObjectId objectId) { - this(objectId.groupId(), objectId.objectIndex()); - } - - public ObjectEditorInputLazy(int groupId, int objectIndex) { - this(groupId, objectIndex, true); + this(objectId, true); } @Override public EditorInput loadRealInput() throws Exception { var game = Application.getInstance().game(); - var object = game.readObject(groupId, objectIndex); - return new ObjectEditorInput(game, object, groupId, objectIndex); + var object = game.readObject(objectId.groupId(), objectId.objectIndex()); + return new ObjectEditorInput(game, object, objectId); } @Override public LazyEditorInput canLoadImmediately(boolean value) { - return new ObjectEditorInputLazy(groupId, objectIndex, value); + return new ObjectEditorInputLazy(objectId, value); } @Override public String getName() { - return "%d:%d".formatted(groupId, objectIndex); + return objectId.toString(); } @Override public String getDescription() { - return "Group: %d\nObject: %d".formatted(groupId, objectIndex); + return "Group: %d\nObject: %d".formatted(objectId.groupId(), objectId.objectIndex()); } @Override public boolean representsSameInput(EditorInput other) { if (other instanceof ObjectEditorInputLazy o) { - return this.groupId == o.groupId && this.objectIndex == o.objectIndex; + return objectId.equals(o.objectId); } if (other instanceof ObjectEditorInput o) { - return this.groupId == o.groupId() && this.objectIndex == o.objectIndex(); + return objectId.equals(o.objectId()); } return false; } diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectViewer.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectViewer.java index 5412c035..bd07f4d8 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectViewer.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/editors/ObjectViewer.java @@ -4,10 +4,9 @@ import sh.adelessfox.odradek.app.ui.component.PreviewManager; import sh.adelessfox.odradek.app.ui.menu.object.ObjectMenu; import sh.adelessfox.odradek.game.Game; -import sh.adelessfox.odradek.game.hfw.rtti.data.StreamingLink; -import sh.adelessfox.odradek.game.hfw.rtti.data.StreamingRef; +import sh.adelessfox.odradek.game.ObjectHolder; +import sh.adelessfox.odradek.game.ObjectIdHolder; import sh.adelessfox.odradek.rtti.*; -import sh.adelessfox.odradek.rtti.data.Ref; import sh.adelessfox.odradek.rtti.data.TypedObject; import sh.adelessfox.odradek.rtti.data.Value; import sh.adelessfox.odradek.ui.Renderer; @@ -69,12 +68,12 @@ private StructuredTree createObjectTree(Game game, TypedObject object) { return; } switch (structure.value()) { - case StreamingRef ref -> { - var input = new ObjectEditorInputLazy(ref.groupId(), ref.objectIndex()); + case ObjectHolder holder when holder.object() instanceof TypedObject typedObject -> { + var input = new ObjectEditorInput(game, typedObject, holder.objectId()); Application.getInstance().editors().openEditor(input); } - case StreamingLink link -> { - var input = new ObjectEditorInput(game, link.get(), link.groupId(), link.objectIndex()); + case ObjectIdHolder holder -> { + var input = new ObjectEditorInputLazy(holder.objectId()); Application.getInstance().editors().openEditor(input); } default -> { @@ -334,9 +333,9 @@ public Optional getType(JTree tree, Object value) { private static Optional get(Object value) { if (value instanceof ObjectStructure structure) { Object object = structure.value(); - if (object instanceof Ref ref) { + if (object instanceof ObjectHolder holder) { // Should this be done here? - object = ref.get(); + object = holder.get(); } if (object instanceof TypedObject typed) { return Optional.of(typed); diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/graph/ExportObjectAction.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/graph/ExportObjectAction.java index ba89a855..b83313ca 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/graph/ExportObjectAction.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/graph/ExportObjectAction.java @@ -7,7 +7,7 @@ import sh.adelessfox.odradek.game.Converter; import sh.adelessfox.odradek.game.Exporter; import sh.adelessfox.odradek.game.Game; -import sh.adelessfox.odradek.game.ObjectHolder; +import sh.adelessfox.odradek.game.ObjectSupplier; import sh.adelessfox.odradek.ui.actions.*; import sh.adelessfox.odradek.ui.actions.Action; import sh.adelessfox.odradek.ui.data.DataKeys; @@ -26,7 +26,7 @@ @ActionRegistration(id = ExportObjectAction.ID, text = "&Export As\u2026", icon = "fugue:blue-document-export", keystroke = "ctrl E") @ActionContribution(parent = GraphMenu.ID, group = "2000,Export") @ActionContribution(parent = MainMenu.File.ID, group = "2000,Export") -@ActionContribution(parent = EditorActionIds.MENU_ID, group = EditorActionIds.MENU_GROUP_GENERAL) +@ActionContribution(parent = EditorActionIds.MENU_ID, group = "4000,Export") @ActionContribution(parent = ObjectEditorActionIds.TOOLBAR_ID, group = ObjectEditorActionIds.TOOLBAR_GROUP_GENERAL) public class ExportObjectAction extends Action { public static final String ID = "sh.adelessfox.odradek.app.menu.graph.ExportObjectAction"; @@ -59,7 +59,7 @@ public boolean isVisible(ActionContext context) { private static Stream> exporters(ActionContext context) { var selection = context.get(DataKeys.SELECTION_LIST).stream() .flatMap(Collection::stream) - .gather(Gatherers.instanceOf(ObjectHolder.class)) + .gather(Gatherers.instanceOf(ObjectSupplier.class)) .toList(); if (selection.isEmpty()) { @@ -67,7 +67,7 @@ private static Stream> exporters(ActionContext context) { } var types = selection.stream() - .map(ObjectHolder::objectType) + .map(ObjectSupplier::objectType) .distinct() .toList(); @@ -105,11 +105,15 @@ private static void exportBatch(Batch batch, Game game) { var directory = chooser.getSelectedFile().toPath(); int exported = 0; - for (ObjectHolder selection : batch.objects()) { + for (ObjectSupplier selection : batch.objects()) { try { var object = selection.readObject(game); var type = object.getType(); - var name = "%s.%s".formatted(selection.objectName(), batch.exporter().extension()); + var name = "%s_%s_%s.%s".formatted( + selection.objectType(), + selection.objectId().groupId(), + selection.objectId().objectIndex(), + batch.exporter().extension()); var path = directory.resolve(name); var converted = batch.converter().convert(object, game); @@ -125,11 +129,11 @@ private static void exportBatch(Batch batch, Game game) { log.debug("Exported object {} ({}) to {}", object, type, path); exported++; } catch (Exception e) { - log.error("Failed to export object", e); + log.error("Failed to export object {} ({})", selection.objectId(), selection.objectType(), e); JOptionPane.showMessageDialog( JOptionPane.getRootFrame(), "Failed to export object: " + e.getMessage(), - "Unable to export object", + "Unable to export object " + selection.objectId(), JOptionPane.ERROR_MESSAGE ); } @@ -154,6 +158,6 @@ private static void exportBatch(Batch batch, Game game) { } } - private record Batch(List objects, Converter converter, Exporter exporter) { + private record Batch(List objects, Converter converter, Exporter exporter) { } } diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/main/file/OpenGraphAction.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/main/file/OpenGraphAction.java index 8f2c9946..40078ce7 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/main/file/OpenGraphAction.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/main/file/OpenGraphAction.java @@ -3,6 +3,7 @@ import sh.adelessfox.odradek.app.ui.Application; import sh.adelessfox.odradek.app.ui.editors.ObjectEditorInput; import sh.adelessfox.odradek.app.ui.menu.main.MainMenu; +import sh.adelessfox.odradek.game.ObjectId; import sh.adelessfox.odradek.ui.actions.Action; import sh.adelessfox.odradek.ui.actions.ActionContext; import sh.adelessfox.odradek.ui.actions.ActionContribution; @@ -15,6 +16,9 @@ public class OpenGraphAction extends Action { public void perform(ActionContext context) { var application = Application.getInstance(); var game = application.game(); - application.editors().openEditor(new ObjectEditorInput(game, game.getStreamingGraph().resource(), 0, 0)); + application.editors().openEditor(new ObjectEditorInput( + game, + game.getStreamingGraph().resource(), + new ObjectId(0, 0))); } } diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/main/file/OpenObjectAction.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/main/file/OpenObjectAction.java index b6cf1f74..32cb4022 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/main/file/OpenObjectAction.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/main/file/OpenObjectAction.java @@ -3,6 +3,7 @@ import sh.adelessfox.odradek.app.ui.Application; import sh.adelessfox.odradek.app.ui.editors.ObjectEditorInputLazy; import sh.adelessfox.odradek.app.ui.menu.main.MainMenu; +import sh.adelessfox.odradek.game.ObjectId; import sh.adelessfox.odradek.ui.actions.Action; import sh.adelessfox.odradek.ui.actions.ActionContext; import sh.adelessfox.odradek.ui.actions.ActionContribution; @@ -32,11 +33,6 @@ public void perform(ActionContext context) { } lastInput = result; - - int colon = result.indexOf(':'); - int groupId = Integer.parseUnsignedInt(result.substring(0, colon).strip()); - int objectIndex = Integer.parseUnsignedInt(result.substring(colon + 1).strip()); - - Application.getInstance().editors().openEditor(new ObjectEditorInputLazy(groupId, objectIndex)); + Application.getInstance().editors().openEditor(new ObjectEditorInputLazy(ObjectId.valueOf(result))); } } diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/settings/SettingsManager.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/settings/SettingsManager.java index 6451e841..6f2a101f 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/settings/SettingsManager.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/settings/SettingsManager.java @@ -9,7 +9,6 @@ import sh.adelessfox.odradek.app.ui.settings.gson.SettingAdapterFactory; import sh.adelessfox.odradek.event.EventBus; import sh.adelessfox.odradek.game.ObjectId; -import sh.adelessfox.odradek.util.OS; import java.io.BufferedReader; import java.io.BufferedWriter; @@ -34,8 +33,8 @@ public final class SettingsManager { private final EventBus eventBus; private final Settings settings; - SettingsManager(String identifier, EventBus eventBus) { - this.path = determinePath(identifier); + SettingsManager(Path path, EventBus eventBus) { + this.path = path; this.eventBus = eventBus; this.settings = load(path).orElseGet(Settings::new); @@ -83,16 +82,4 @@ private void save() { log.error("Error while saving settings", e); } } - - private static Path determinePath(String identifier) { - String userHome = System.getProperty("user.home"); - if (userHome == null) { - throw new IllegalStateException("Unable to determine user home directory"); - } - return switch (OS.name()) { - case WINDOWS -> Path.of(userHome, "AppData", "Local", identifier, "settings.json"); - case MACOS -> Path.of(userHome, "Library", "Application Support", identifier, "settings.json"); - case LINUX -> Path.of(userHome, ".config", identifier, "settings.json"); - }; - } } diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/settings/SettingsModule.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/settings/SettingsModule.java index 6442728e..218ac108 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/settings/SettingsModule.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/settings/SettingsModule.java @@ -2,15 +2,18 @@ import dagger.Module; import dagger.Provides; +import jakarta.inject.Named; import jakarta.inject.Singleton; import sh.adelessfox.odradek.event.EventBus; +import java.nio.file.Path; + @Module public interface SettingsModule { @Provides @Singleton - static SettingsManager provideSettingsManager(EventBus eventBus) { - return new SettingsManager("Odradek", eventBus); + static SettingsManager provideSettingsManager(@Named("config") Path config, EventBus eventBus) { + return new SettingsManager(config.resolve("settings.json"), eventBus); } @Provides diff --git a/odradek-core/src/main/java/sh/adelessfox/odradek/io/BinaryReader.java b/odradek-core/src/main/java/sh/adelessfox/odradek/io/BinaryReader.java index bae9323f..0b3c9ca4 100644 --- a/odradek-core/src/main/java/sh/adelessfox/odradek/io/BinaryReader.java +++ b/odradek-core/src/main/java/sh/adelessfox/odradek/io/BinaryReader.java @@ -28,15 +28,15 @@ static BinaryReader wrap(ByteBuffer buffer) { if (!buffer.hasArray()) { throw new IllegalArgumentException("Buffer must be backed by an array"); } - return new ByteArrayBinaryReader(buffer.array(), buffer.arrayOffset() + buffer.position(), buffer.remaining()); + return new BytesBinaryReader(buffer.array(), buffer.arrayOffset() + buffer.position(), buffer.remaining()); } static BinaryReader wrap(byte[] array) { - return new ByteArrayBinaryReader(array, 0, array.length); + return new BytesBinaryReader(array, 0, array.length); } static BinaryReader wrap(byte[] array, int off, int len) { - return new ByteArrayBinaryReader(array, off, len); + return new BytesBinaryReader(array, off, len); } static BinaryReader open(Path path) throws IOException { @@ -191,7 +191,7 @@ default List readObjects(int count, Mapper mapper) throws IOException long position() throws IOException; - void position(long pos) throws IOException; + BinaryReader position(long pos) throws IOException; ByteOrder order(); diff --git a/odradek-core/src/main/java/sh/adelessfox/odradek/io/BinaryWriter.java b/odradek-core/src/main/java/sh/adelessfox/odradek/io/BinaryWriter.java new file mode 100644 index 00000000..803ddb31 --- /dev/null +++ b/odradek-core/src/main/java/sh/adelessfox/odradek/io/BinaryWriter.java @@ -0,0 +1,77 @@ +package sh.adelessfox.odradek.io; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteOrder; +import java.nio.file.Path; + +public interface BinaryWriter extends Closeable { + @FunctionalInterface + interface Mapper { + void write(T value, BinaryWriter writer) throws IOException; + } + + static BinaryWriter open(Path path) throws IOException { + return ChannelBinaryWriter.open(path); + } + + void writeByte(byte value) throws IOException; + + void writeShort(short value) throws IOException; + + void writeInt(int value) throws IOException; + + void writeLong(long value) throws IOException; + + default void writeFloat(float value) throws IOException { + writeInt(Float.floatToIntBits(value)); + } + + default void writeDouble(double value) throws IOException { + writeLong(Double.doubleToLongBits(value)); + } + + default void writeBytes(byte[] values) throws IOException { + for (byte value : values) { + writeByte(value); + } + } + + default void writeShorts(short[] values) throws IOException { + for (short value : values) { + writeShort(value); + } + } + + default void writeInts(int[] values) throws IOException { + for (int value : values) { + writeInt(value); + } + } + + default void writeLongs(long[] values) throws IOException { + for (long value : values) { + writeLong(value); + } + } + + default void writeFloats(float[] values) throws IOException { + for (float value : values) { + writeFloat(value); + } + } + + default void writeDoubles(double[] values) throws IOException { + for (double value : values) { + writeDouble(value); + } + } + + long position() throws IOException; + + BinaryWriter position(long pos) throws IOException; + + ByteOrder order(); + + BinaryWriter order(ByteOrder order) throws IOException; +} diff --git a/odradek-core/src/main/java/sh/adelessfox/odradek/io/ByteArrayBinaryReader.java b/odradek-core/src/main/java/sh/adelessfox/odradek/io/BytesBinaryReader.java similarity index 92% rename from odradek-core/src/main/java/sh/adelessfox/odradek/io/ByteArrayBinaryReader.java rename to odradek-core/src/main/java/sh/adelessfox/odradek/io/BytesBinaryReader.java index 8b6731d3..4f76c835 100644 --- a/odradek-core/src/main/java/sh/adelessfox/odradek/io/ByteArrayBinaryReader.java +++ b/odradek-core/src/main/java/sh/adelessfox/odradek/io/BytesBinaryReader.java @@ -5,14 +5,14 @@ import java.nio.ByteOrder; import java.util.Objects; -final class ByteArrayBinaryReader implements BinaryReader { +final class BytesBinaryReader implements BinaryReader { private final byte[] array; private final int offset; private final int length; private int position; private ByteOrder order = ByteOrder.LITTLE_ENDIAN; - ByteArrayBinaryReader(byte[] array, int offset, int length) { + BytesBinaryReader(byte[] array, int offset, int length) { Objects.checkFromIndexSize(offset, length, array.length); this.array = array; this.offset = offset; @@ -79,10 +79,11 @@ public long position() { } @Override - public void position(long position) { + public BinaryReader position(long position) { int pos = Math.toIntExact(position); Objects.checkIndex(pos, length + 1); this.position = pos; + return this; } @Override diff --git a/odradek-core/src/main/java/sh/adelessfox/odradek/io/BytesBinaryWriter.java b/odradek-core/src/main/java/sh/adelessfox/odradek/io/BytesBinaryWriter.java new file mode 100644 index 00000000..7ffcc5ef --- /dev/null +++ b/odradek-core/src/main/java/sh/adelessfox/odradek/io/BytesBinaryWriter.java @@ -0,0 +1,94 @@ +package sh.adelessfox.odradek.io; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Objects; + +public final class BytesBinaryWriter implements BinaryWriter { + private ByteBuffer buffer; + private int length = 0; + + public BytesBinaryWriter() { + this(32); + } + + public BytesBinaryWriter(int size) { + if (size < 0) { + throw new IllegalArgumentException("Negative initial size: " + size); + } + buffer = ByteBuffer.allocate(size).order(ByteOrder.LITTLE_ENDIAN); + } + + @Override + public void writeByte(byte value) { + reserve(Byte.BYTES); + buffer.put(value); + } + + @Override + public void writeShort(short value) { + reserve(Short.BYTES); + buffer.putShort(value); + } + + @Override + public void writeInt(int value) { + reserve(Integer.BYTES); + buffer.putInt(value); + } + + @Override + public void writeLong(long value) { + reserve(Long.BYTES); + buffer.putLong(value); + } + + @Override + public long position() { + return buffer.position(); + } + + @Override + public BinaryWriter position(long pos) { + Objects.checkIndex(pos, Integer.MAX_VALUE); + int curPos = buffer.position(); + int newPos = Math.toIntExact(Math.max(curPos, pos)); + if (curPos != newPos) { + reserve(newPos - curPos); + buffer.position(newPos); + } + return this; + } + + @Override + public ByteOrder order() { + return buffer.order(); + } + + @Override + public BinaryWriter order(ByteOrder order) { + buffer.order(order); + return this; + } + + @Override + public void close() { + buffer = null; + } + + public byte[] toByteArray() { + byte[] dst = new byte[length]; + buffer.get(0, dst, 0, length); + return dst; + } + + private void reserve(int count) { + if (buffer.remaining() < count) { + buffer = ByteBuffer + .allocate(Math.max(buffer.capacity() * 2, buffer.capacity() + count)) + .order(buffer.order()) + .put(buffer.flip()); + } + length = Math.max(length, buffer.position() + count); + } +} diff --git a/odradek-core/src/main/java/sh/adelessfox/odradek/io/ChannelBinaryReader.java b/odradek-core/src/main/java/sh/adelessfox/odradek/io/ChannelBinaryReader.java index e406a8a0..6d5352fc 100644 --- a/odradek-core/src/main/java/sh/adelessfox/odradek/io/ChannelBinaryReader.java +++ b/odradek-core/src/main/java/sh/adelessfox/odradek/io/ChannelBinaryReader.java @@ -112,7 +112,7 @@ public long position() { } @Override - public void position(long pos) throws IOException { + public BinaryReader position(long pos) throws IOException { Objects.checkIndex(pos, length + 1); if (pos >= position && pos < position + buffer.limit()) { @@ -122,6 +122,8 @@ public void position(long pos) throws IOException { buffer.limit(0); channel.position(pos); } + + return this; } @Override diff --git a/odradek-core/src/main/java/sh/adelessfox/odradek/io/ChannelBinaryWriter.java b/odradek-core/src/main/java/sh/adelessfox/odradek/io/ChannelBinaryWriter.java new file mode 100644 index 00000000..8e6cffe2 --- /dev/null +++ b/odradek-core/src/main/java/sh/adelessfox/odradek/io/ChannelBinaryWriter.java @@ -0,0 +1,109 @@ +package sh.adelessfox.odradek.io; + +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.FileChannel; +import java.nio.file.Path; + +import static java.nio.file.StandardOpenOption.*; + +final class ChannelBinaryWriter implements BinaryWriter { + private final ByteBuffer buffer = ByteBuffer.allocate(16384).order(ByteOrder.LITTLE_ENDIAN); + private final FileChannel channel; + private long position; + + private ChannelBinaryWriter(FileChannel channel) { + this.channel = channel; + } + + static ChannelBinaryWriter open(Path path) throws IOException { + return new ChannelBinaryWriter(FileChannel.open(path, WRITE, CREATE, TRUNCATE_EXISTING)); + } + + @Override + public void writeByte(byte value) throws IOException { + reserve(Byte.BYTES); + buffer.put(value); + } + + @Override + public void writeShort(short value) throws IOException { + reserve(Short.BYTES); + buffer.putShort(value); + } + + @Override + public void writeInt(int value) throws IOException { + reserve(Integer.BYTES); + buffer.putInt(value); + } + + @Override + public void writeLong(long value) throws IOException { + reserve(Long.BYTES); + buffer.putLong(value); + } + + @Override + public long position() { + return position + buffer.position(); + } + + @Override + public BinaryWriter position(long pos) throws IOException { + if (position() != pos) { + flush(); + channel.position(pos); + position = pos; + } + return this; + } + + @Override + public ByteOrder order() { + return buffer.order(); + } + + @Override + public BinaryWriter order(ByteOrder order) throws IOException { + if (buffer.order() != order) { + flush(); + buffer.order(order); + } + return this; + } + + @Override + public void close() throws IOException { + flush(); + } + + private void reserve(int count) throws IOException { + if (buffer.capacity() < count) { + throw new IllegalArgumentException("Can't reserve more bytes than the buffer can hold"); + } + if (buffer.remaining() < count) { + flush(); + } + } + + private void flush() throws IOException { + int count = buffer.position(); + if (count > 0) { + buffer.flip(); + write(buffer); + buffer.clear(); + position += count; + } + } + + private void write(ByteBuffer src) throws IOException { + while (src.hasRemaining()) { + if (channel.write(src) < 0) { + throw new EOFException(); + } + } + } +} diff --git a/odradek-core/src/main/java/sh/adelessfox/odradek/io/ChunkedBinaryReader.java b/odradek-core/src/main/java/sh/adelessfox/odradek/io/ChunkedBinaryReader.java index 9e21d36c..5452eeef 100644 --- a/odradek-core/src/main/java/sh/adelessfox/odradek/io/ChunkedBinaryReader.java +++ b/odradek-core/src/main/java/sh/adelessfox/odradek/io/ChunkedBinaryReader.java @@ -111,9 +111,10 @@ public long position() { } @Override - public void position(long pos) throws IOException { + public BinaryReader position(long pos) throws IOException { Objects.checkIndex(pos, size() + 1); position = pos; + return this; } @Override diff --git a/odradek-core/src/main/java/sh/adelessfox/odradek/io/DirectStorageReader.java b/odradek-core/src/main/java/sh/adelessfox/odradek/io/DirectStorageReader.java index 062abdb6..85fdb6a6 100644 --- a/odradek-core/src/main/java/sh/adelessfox/odradek/io/DirectStorageReader.java +++ b/odradek-core/src/main/java/sh/adelessfox/odradek/io/DirectStorageReader.java @@ -7,11 +7,11 @@ import java.util.List; /** - * A reader for DirectStorage archive. + * A reader for DirectStorage archives. * * @see DirectStorage archive format */ -public class DirectStorageReader extends ChunkedBinaryReader { +public final class DirectStorageReader extends ChunkedBinaryReader { private final Header header; private DirectStorageReader(BinaryReader reader, Header header, List chunks) { diff --git a/odradek-game-hfw-ui/src/main/java/sh/adelessfox/odradek/game/hfw/ui/renderers/attr/EnumFact$DefaultValueUUIDRenderer.java b/odradek-game-hfw-ui/src/main/java/sh/adelessfox/odradek/game/hfw/ui/renderers/attr/EnumFact$DefaultValueUUIDRenderer.java index 40343e21..355244dc 100644 --- a/odradek-game-hfw-ui/src/main/java/sh/adelessfox/odradek/game/hfw/ui/renderers/attr/EnumFact$DefaultValueUUIDRenderer.java +++ b/odradek-game-hfw-ui/src/main/java/sh/adelessfox/odradek/game/hfw/ui/renderers/attr/EnumFact$DefaultValueUUIDRenderer.java @@ -3,10 +3,10 @@ import sh.adelessfox.odradek.game.hfw.game.ForbiddenWestGame; import sh.adelessfox.odradek.game.hfw.rtti.HorizonForbiddenWest.EnumFact; import sh.adelessfox.odradek.game.hfw.rtti.HorizonForbiddenWest.EnumFactEntry; +import sh.adelessfox.odradek.game.hfw.rtti.data.ref.Ref; import sh.adelessfox.odradek.rtti.ClassAttrInfo; import sh.adelessfox.odradek.rtti.ClassTypeInfo; import sh.adelessfox.odradek.rtti.TypeInfo; -import sh.adelessfox.odradek.rtti.data.Ref; import sh.adelessfox.odradek.ui.Renderer; import sh.adelessfox.odradek.ui.components.StyledFragment; import sh.adelessfox.odradek.ui.components.StyledText; diff --git a/odradek-game-hfw/src/main/java/module-info.java b/odradek-game-hfw/src/main/java/module-info.java index 5b7e0a3f..453ed58a 100644 --- a/odradek-game-hfw/src/main/java/module-info.java +++ b/odradek-game-hfw/src/main/java/module-info.java @@ -1,4 +1,5 @@ import sh.adelessfox.odradek.game.hfw.rtti.callbacks.*; +import sh.adelessfox.odradek.game.hfw.rtti.data.ref.*; import sh.adelessfox.odradek.game.hfw.rtti.extensions.*; import sh.adelessfox.odradek.rtti.generator.TypeBindings; import sh.adelessfox.odradek.rtti.generator.TypeBindings.Builtin; @@ -14,6 +15,7 @@ ), target = "sh.adelessfox.odradek.game.hfw.rtti.HorizonForbiddenWest", builtins = { + // atoms @Builtin(type = "bool", repr = boolean.class), @Builtin(type = "int", repr = int.class), @Builtin(type = "int8", repr = byte.class), @@ -36,6 +38,13 @@ @Builtin(type = "ucs4", repr = int.class), @Builtin(type = "String", repr = String.class), @Builtin(type = "WString", repr = String.class), + + // pointers + @Builtin(type = "Ref", repr = Ref.class), + @Builtin(type = "StreamingRef", repr = StreamingRef.class), + @Builtin(type = "UUIDRef", repr = UUIDRef.class), + @Builtin(type = "WeakPtr", repr = WeakPtr.class), + @Builtin(type = "cptr", repr = CPtr.class), }, callbacks = { @Callback(type = "DataBufferResource", handler = DataBufferResourceCallback.class), @@ -83,6 +92,7 @@ exports sh.adelessfox.odradek.game.hfw.game; exports sh.adelessfox.odradek.game.hfw.rtti.callbacks; + exports sh.adelessfox.odradek.game.hfw.rtti.data.ref; exports sh.adelessfox.odradek.game.hfw.rtti.data; exports sh.adelessfox.odradek.game.hfw.rtti.extensions; exports sh.adelessfox.odradek.game.hfw.rtti; diff --git a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/converters/scene/BaseSceneConverter.java b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/converters/scene/BaseSceneConverter.java index 8641e133..3791d918 100644 --- a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/converters/scene/BaseSceneConverter.java +++ b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/converters/scene/BaseSceneConverter.java @@ -5,10 +5,10 @@ import sh.adelessfox.odradek.game.Converter; import sh.adelessfox.odradek.game.hfw.game.ForbiddenWestGame; import sh.adelessfox.odradek.game.hfw.rtti.HorizonForbiddenWest; +import sh.adelessfox.odradek.game.hfw.rtti.data.ref.Ref; import sh.adelessfox.odradek.geometry.*; import sh.adelessfox.odradek.math.Matrix4f; import sh.adelessfox.odradek.math.Vector3f; -import sh.adelessfox.odradek.rtti.data.Ref; import sh.adelessfox.odradek.scene.Scene; import java.nio.ByteBuffer; diff --git a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/converters/scene/MeshToSceneConverter.java b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/converters/scene/MeshToSceneConverter.java index baf4eb56..15bad6e3 100644 --- a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/converters/scene/MeshToSceneConverter.java +++ b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/converters/scene/MeshToSceneConverter.java @@ -6,10 +6,10 @@ import sh.adelessfox.odradek.game.hfw.data.edge.EdgeAnimJointTransform; import sh.adelessfox.odradek.game.hfw.data.edge.EdgeAnimSkeleton; import sh.adelessfox.odradek.game.hfw.game.ForbiddenWestGame; +import sh.adelessfox.odradek.game.hfw.rtti.data.ref.Ref; import sh.adelessfox.odradek.io.BinaryReader; import sh.adelessfox.odradek.math.Matrix4f; import sh.adelessfox.odradek.rtti.TypeInfo; -import sh.adelessfox.odradek.rtti.data.Ref; import sh.adelessfox.odradek.scene.Joint; import sh.adelessfox.odradek.scene.Node; import sh.adelessfox.odradek.scene.Scene; diff --git a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/game/LinkDatabase.java b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/game/LinkDatabase.java new file mode 100644 index 00000000..52b4c413 --- /dev/null +++ b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/game/LinkDatabase.java @@ -0,0 +1,297 @@ +package sh.adelessfox.odradek.game.hfw.game; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sh.adelessfox.odradek.game.LinkProvider; +import sh.adelessfox.odradek.game.ObjectId; +import sh.adelessfox.odradek.game.ObjectIdHolder; +import sh.adelessfox.odradek.game.hfw.rtti.HorizonForbiddenWest.StreamingGroupData; +import sh.adelessfox.odradek.game.hfw.storage.StreamingGraphResource; +import sh.adelessfox.odradek.game.hfw.storage.StreamingObjectReader; +import sh.adelessfox.odradek.hashing.HashCode; +import sh.adelessfox.odradek.hashing.HashFunction; +import sh.adelessfox.odradek.io.BinaryReader; +import sh.adelessfox.odradek.io.BinaryWriter; +import sh.adelessfox.odradek.io.BytesBinaryWriter; +import sh.adelessfox.odradek.rtti.*; +import sh.adelessfox.odradek.rtti.data.TypePath; +import sh.adelessfox.odradek.rtti.data.TypeVisitor; +import sh.adelessfox.odradek.rtti.data.TypedObject; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.stream.IntStream; + +public final class LinkDatabase implements LinkProvider { + private static final Logger log = LoggerFactory.getLogger(LinkDatabase.class); + + private static final int FILE_MAGIC = 'G' | 'R' << 8 | 'P' << 16 | 'H' << 24; + private static final int FILE_VERSION = 1; + + private final ForbiddenWestGame game; + private final BinaryReader reader; + private final int[] offsets; + + private LinkDatabase(ForbiddenWestGame game, BinaryReader reader, int[] offsets) { + this.game = game; + this.reader = reader; + this.offsets = offsets; + } + + public static LinkDatabase open(ForbiddenWestGame game, Path path) throws IOException { + var reader = BinaryReader.open(path); + try { + int magic = reader.readInt(); + if (magic != FILE_MAGIC) { + throw new IllegalArgumentException("Invalid database file magic"); + } + + int version = reader.readInt(); + if (version != FILE_VERSION) { + throw new IllegalArgumentException("Unsupported database file version"); + } + + long checksum = reader.readLong(); + if (checksum != computeHash(game).asLong()) { + throw new IllegalArgumentException("Link table checksum mismatch"); + } + + int count = game.getStreamingGraph().types().size(); + var offsets = reader.readInts(count); + return new LinkDatabase(game, reader, offsets); + } catch (Exception e) { + reader.close(); + throw e; + } + } + + public static void build( + ForbiddenWestGame game, + Path path, + BiConsumer progress + ) throws IOException { + var graph = game.getStreamingGraph(); + var reader = game.getStreamingReader(); + + int objects = graph.types().size(); + var links = IntStream.range(0, objects) + .mapToObj(_ -> new ArrayList()) + .toList(); + + log.debug("Scanning graph groups..."); + for (int i = 0; i < graph.groups().size(); i++) { + var group = graph.groups().get(i); + progress.accept(i + 1, graph.groups().size()); + + var info = visitGroup(group, reader); + for (int j = 0; j < info.objects().size(); j++) { + var object = info.objects().get(j); + for (LinkProvider.Link link : object.out()) { + var targetGroup = graph.group(link.groupId()); + int targetObject = targetGroup.typeStart() + link.objectIndex(); + links.get(targetObject).add(PackedLink.pack(group.groupID(), j, link.path())); + } + } + } + + log.debug("Serializing the database..."); + try (BinaryWriter writer = BinaryWriter.open(path)) { + var offsets = new int[objects]; + + writer.position(32 + (long) objects * Integer.BYTES); + for (int i = 0; i < objects; i++) { + offsets[i] = Math.toIntExact(writer.position()); + + writeVarInt(writer, links.get(i).size()); + for (PackedLink link : links.get(i)) { + writeLink(link, writer); + } + } + + writer.position(0); + writer.writeInt(FILE_MAGIC); + writer.writeInt(FILE_VERSION); + writer.writeLong(computeHash(game).asLong()); + writer.writeInts(offsets); + } + } + + public static HashCode computeHash(ForbiddenWestGame game) throws IOException { + var graph = game.getStreamingGraph(); + var system = game.getStreamingSystem(); + var linkTable = system.getFileData(Math.toIntExact(graph.linkTableID()), 0, graph.linkTableSize()); + return HashFunction.murmur3().hash(linkTable); + } + + @Override + public List getIncomingLinks(ObjectId target) throws IOException { + var graph = game.getStreamingGraph(); + var group = graph.group(target.groupId()); + int index = group.typeStart() + target.objectIndex(); + + synchronized (reader) { + return reader + .position(offsets[index]) + .readObjects(readVarInt(reader), r -> readLink(graph, r)); + } + } + + @Override + public List getOutgoingLinks(ObjectId source) throws IOException { + var object = game.readObject(source.groupId(), source.objectIndex()); + var info = visitObject(object); + return info.out(); + } + + @Override + public void close() throws IOException { + reader.close(); + } + + private static GroupInfo visitGroup(StreamingGroupData group, StreamingObjectReader reader) throws IOException { + var result = reader.readGroup(group.groupID()); + var objects = new ArrayList(group.numObjects()); + + for (int i = 0; i < group.numObjects(); i++) { + objects.add(visitObject(result.objects().get(i).object())); + } + + return new GroupInfo(group, objects); + } + + private static ObjectInfo visitObject(TypedObject object) { + var links = new ArrayList(); + var visitor = new TypeVisitor() { + @Override + protected void visitContainer(ContainerTypeInfo typeInfo, Object object, TypePath.Builder builder) { + if (typeInfo.itemType() instanceof AtomTypeInfo) { + return; + } + super.visitContainer(typeInfo, object, builder); + } + + @Override + protected void visitPointer(PointerTypeInfo typeInfo, Object object, TypePath.Builder builder) { + if (object instanceof ObjectIdHolder holder) { + var objectId = holder.objectId(); + links.add(new Link(objectId.groupId(), objectId.objectIndex(), builder.build())); + } + } + }; + + visitor.visit(object.getType(), object); + + return new ObjectInfo(links); + } + + private static LinkProvider.Link readLink(StreamingGraphResource graph, BinaryReader reader) throws IOException { + int groupId = readVarInt(reader); + int objectIndex = readVarInt(reader); + var objectType = graph.types().get(graph.group(groupId).typeStart() + objectIndex); + var path = readPath(objectType, reader); + return new LinkProvider.Link(groupId, objectIndex, path); + } + + private static void writeLink(PackedLink link, BinaryWriter writer) throws IOException { + writeVarInt(writer, link.groupId()); + writeVarInt(writer, link.objectIndex()); + writeVarInt(writer, link.path().length); + writer.writeBytes(link.path()); + } + + private static TypePath readPath(ClassTypeInfo root, BinaryReader reader) throws IOException { + var elements = new ArrayList(); + var parent = (TypeInfo) root; + + for (long end = readVarInt(reader) + reader.position(); reader.position() < end; ) { + int element = readVarInt(reader); + int index = element >>> 1; + if ((element & 1) == 0) { + var type = parent.asClass(); + var attr = type.orderedAttrs().get(index); + elements.add(new TypePath.Element.Attr(type, attr)); + parent = attr.type(); + } else { + var type = parent.asContainer(); + elements.add(new TypePath.Element.Index(type, index)); + parent = type.itemType(); + } + } + + return new TypePath(elements); + } + + private static void writePath(TypePath path, BinaryWriter writer) throws IOException { + for (TypePath.Element element : path.elements()) { + switch (element) { + // @formatter:off + case TypePath.Element.Attr(var type, var attr) -> + writeVarInt(writer, type.orderedAttrs().indexOf(attr) << 1); + case TypePath.Element.Index(_, int index) -> + writeVarInt(writer, index << 1 | 1); + // @formatter:on + } + } + } + + private static int readVarInt(BinaryReader reader) throws IOException { + int len = 0; + int out = 0; + while (true) { + int tmp = reader.readByte(); + out |= (tmp & 0x7F) << len; + if ((tmp & 0x80) == 0) { + break; + } + len += 7; + if (len >= 32) { + throw new IOException("VarInt too long"); + } + } + return out; + } + + private static void writeVarInt(BinaryWriter writer, int value) throws IOException { + while ((value & ~0x7F) != 0) { + writer.writeByte((byte) ((value & 0x7F) | 0x80)); + value >>>= 7; + } + writer.writeByte((byte) value); + } + + private record ObjectInfo(List out) { + private ObjectInfo { + out = List.copyOf(out); + } + + @Override + public String toString() { + return "ObjectInfo{out=" + out.size() + '}'; + } + } + + private record GroupInfo(StreamingGroupData data, List objects) { + private GroupInfo { + objects = List.copyOf(objects); + } + + @Override + public String toString() { + return "GroupInfo{objects=" + objects.size() + '}'; + } + } + + private record PackedLink(int groupId, int objectIndex, byte[] path) { + static PackedLink pack(int groupId, int objectIndex, TypePath path) throws IOException { + byte[] bytes; + try (BytesBinaryWriter writer = new BytesBinaryWriter()) { + writePath(path, writer); + bytes = writer.toByteArray(); + } + return new PackedLink(groupId, objectIndex, bytes); + } + } +} diff --git a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/HFWTypeReader.java b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/HFWTypeReader.java index dff010fd..c1c5e80a 100644 --- a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/HFWTypeReader.java +++ b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/HFWTypeReader.java @@ -3,7 +3,6 @@ import sh.adelessfox.odradek.hashing.HashFunction; import sh.adelessfox.odradek.io.BinaryReader; import sh.adelessfox.odradek.rtti.*; -import sh.adelessfox.odradek.rtti.data.Ref; import sh.adelessfox.odradek.rtti.data.Value; import sh.adelessfox.odradek.rtti.factory.TypeFactory; import sh.adelessfox.odradek.rtti.io.AbstractTypeReader; @@ -77,7 +76,7 @@ protected Object readContainer(ContainerTypeInfo info, BinaryReader reader, Type } @Override - protected Ref readPointer(PointerTypeInfo info, BinaryReader reader, TypeFactory factory) throws IOException { + protected Object readPointer(PointerTypeInfo info, BinaryReader reader, TypeFactory factory) throws IOException { throw new IOException("Unexpected pointer"); } diff --git a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/StreamingLink.java b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/StreamingLink.java deleted file mode 100644 index dfe713a9..00000000 --- a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/StreamingLink.java +++ /dev/null @@ -1,26 +0,0 @@ -package sh.adelessfox.odradek.game.hfw.rtti.data; - -import sh.adelessfox.odradek.game.hfw.rtti.HorizonForbiddenWest.RTTIRefObject; -import sh.adelessfox.odradek.rtti.data.Ref; - -public record StreamingLink(T object, int groupId, int objectIndex) implements Ref { - @Override - public T get() { - return object; - } - - @Override - public boolean equals(Object obj) { - return this == obj; - } - - @Override - public int hashCode() { - return System.identityHashCode(this); - } - - @Override - public String toString() { - return ""; - } -} diff --git a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/StreamingRef.java b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/StreamingRef.java deleted file mode 100644 index e5af2362..00000000 --- a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/StreamingRef.java +++ /dev/null @@ -1,15 +0,0 @@ -package sh.adelessfox.odradek.game.hfw.rtti.data; - -import sh.adelessfox.odradek.rtti.data.Ref; - -public record StreamingRef(int groupId, int objectIndex) implements Ref { - @Override - public String toString() { - return ""; - } - - @Override - public T get() { - return null; - } -} diff --git a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/UUIDRef.java b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/UUIDRef.java deleted file mode 100644 index d8aab8ee..00000000 --- a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/UUIDRef.java +++ /dev/null @@ -1,16 +0,0 @@ -package sh.adelessfox.odradek.game.hfw.rtti.data; - -import sh.adelessfox.odradek.game.hfw.rtti.HorizonForbiddenWest; -import sh.adelessfox.odradek.rtti.data.Ref; - -public record UUIDRef(HorizonForbiddenWest.GGUUID objectUUID) implements Ref { - @Override - public T get() { - return null; - } - - @Override - public String toString() { - return ""; - } -} diff --git a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/ref/CPtr.java b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/ref/CPtr.java new file mode 100644 index 00000000..585ef36d --- /dev/null +++ b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/ref/CPtr.java @@ -0,0 +1,19 @@ +package sh.adelessfox.odradek.game.hfw.rtti.data.ref; + +import sh.adelessfox.odradek.game.ObjectHolder; +import sh.adelessfox.odradek.game.ObjectId; + +public record CPtr(ObjectId objectId, T object) implements ObjectHolder { + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + return obj instanceof CPtr that && objectId.equals(that.objectId); + } + + @Override + public int hashCode() { + return objectId.hashCode(); + } +} diff --git a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/ref/Ref.java b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/ref/Ref.java new file mode 100644 index 00000000..f0e908a7 --- /dev/null +++ b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/ref/Ref.java @@ -0,0 +1,45 @@ +package sh.adelessfox.odradek.game.hfw.rtti.data.ref; + +import sh.adelessfox.odradek.game.ObjectHolder; +import sh.adelessfox.odradek.game.ObjectId; +import sh.adelessfox.odradek.rtti.data.TypedObject; + +import java.util.Iterator; + +public record Ref(ObjectId objectId, T object) implements ObjectHolder { + public static Iterable unwrap(Iterable> iterable) { + return () -> unwrap(iterable.iterator()); + } + + public static Iterator unwrap(Iterator> iterator) { + return new Iterator<>() { + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public T next() { + return iterator.next().get(); + } + }; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + return obj instanceof Ref that && objectId.equals(that.objectId); + } + + @Override + public int hashCode() { + return objectId.hashCode(); + } + + @Override + public String toString() { + return ""; + } +} diff --git a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/ref/StreamingRef.java b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/ref/StreamingRef.java new file mode 100644 index 00000000..80ddd105 --- /dev/null +++ b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/ref/StreamingRef.java @@ -0,0 +1,12 @@ +package sh.adelessfox.odradek.game.hfw.rtti.data.ref; + +import sh.adelessfox.odradek.game.ObjectId; +import sh.adelessfox.odradek.game.ObjectIdHolder; + +@SuppressWarnings("unused") +public record StreamingRef(ObjectId objectId) implements ObjectIdHolder { + @Override + public String toString() { + return ""; + } +} diff --git a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/ref/UUIDRef.java b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/ref/UUIDRef.java new file mode 100644 index 00000000..be4d6dde --- /dev/null +++ b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/ref/UUIDRef.java @@ -0,0 +1,11 @@ +package sh.adelessfox.odradek.game.hfw.rtti.data.ref; + +import sh.adelessfox.odradek.game.hfw.rtti.HorizonForbiddenWest.GGUUID; + +@SuppressWarnings("unused") +public record UUIDRef(GGUUID uuid) { + @Override + public String toString() { + return ""; + } +} diff --git a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/ref/WeakPtr.java b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/ref/WeakPtr.java new file mode 100644 index 00000000..0a8db116 --- /dev/null +++ b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/rtti/data/ref/WeakPtr.java @@ -0,0 +1,25 @@ +package sh.adelessfox.odradek.game.hfw.rtti.data.ref; + +import sh.adelessfox.odradek.game.ObjectHolder; +import sh.adelessfox.odradek.game.ObjectId; +import sh.adelessfox.odradek.game.hfw.rtti.HorizonForbiddenWest.RTTIRefObject; + +public record WeakPtr(ObjectId objectId, T object) implements ObjectHolder { + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + return obj instanceof WeakPtr that && objectId.equals(that.objectId); + } + + @Override + public int hashCode() { + return objectId.hashCode(); + } + + @Override + public String toString() { + return ""; + } +} diff --git a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/storage/StreamingObjectReader.java b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/storage/StreamingObjectReader.java index af9e094d..0c85a965 100644 --- a/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/storage/StreamingObjectReader.java +++ b/odradek-game-hfw/src/main/java/sh/adelessfox/odradek/game/hfw/storage/StreamingObjectReader.java @@ -2,20 +2,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sh.adelessfox.odradek.game.ObjectId; import sh.adelessfox.odradek.game.hfw.rtti.HFWTypeReader; -import sh.adelessfox.odradek.game.hfw.rtti.data.StreamingLink; -import sh.adelessfox.odradek.game.hfw.rtti.data.StreamingRef; -import sh.adelessfox.odradek.game.hfw.rtti.data.UUIDRef; +import sh.adelessfox.odradek.game.hfw.rtti.data.ref.*; import sh.adelessfox.odradek.io.BinaryReader; import sh.adelessfox.odradek.rtti.ClassTypeInfo; import sh.adelessfox.odradek.rtti.PointerTypeInfo; -import sh.adelessfox.odradek.rtti.data.Ref; import sh.adelessfox.odradek.rtti.factory.TypeFactory; import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; +import java.util.*; import static sh.adelessfox.odradek.game.hfw.rtti.HorizonForbiddenWest.*; @@ -26,7 +22,7 @@ public class StreamingObjectReader extends HFWTypeReader { private final StreamingGraphResource graph; private final TypeFactory factory; - private final LruWeakCache cache = new LruWeakCache<>(5000); + private final LruWeakCache cache = new LruWeakCache<>(5000); private GroupResult currentGroup; private List currentSubGroups; @@ -61,34 +57,32 @@ public GroupResult readGroup(int id) throws IOException { } public GroupResult readGroup(int id, boolean readSubgroups) throws IOException { - var groups = new ArrayList(); - var result = readGroup(id, groups, readSubgroups); - assert result == groups.getLast(); - return result; + return readGroup(id, new HashMap<>(), readSubgroups); } - private GroupResult readGroup(int id, List groups, boolean readSubgroups) throws IOException { + private GroupResult readGroup(int id, Map cache, boolean readSubgroups) throws IOException { var group = Objects.requireNonNull(graph.group(id), () -> "Group not found: " + id); if (log.isDebugEnabled()) { log.debug("{}Reading group {}", indent(), Colors.blue(id)); } - for (GroupResult result : groups) { - if (result.group == group) { - return result; - } + var result = cache.get(group.groupID()); + if (result == null) { + depth++; + result = readGroup(group, cache, readSubgroups); + cache.put(result.group.groupID(), result); + depth--; } - depth++; - var result = readGroup(group, groups, readSubgroups); - groups.add(result); - depth--; - return result; } - private synchronized GroupResult readGroup(StreamingGroupData group, List groups, boolean readSubgroups) throws IOException { + private synchronized GroupResult readGroup( + StreamingGroupData group, + Map groups, + boolean readSubgroups + ) throws IOException { var subGroups = new ArrayList(group.subGroupCount()); if (readSubgroups) { for (int i = 0; i < group.subGroupCount(); i++) { @@ -99,10 +93,10 @@ private synchronized GroupResult readGroup(StreamingGroupData group, List readPointer(PointerTypeInfo info, BinaryReader reader, TypeFactory factory) throws IOException { - // FIXME: - // An instance of Ref might be null if it's absent or it can't be resolved (StreamingRef) - // Ref#get might also return null if it doesn't hold an immediate reference (UUIDRef, StreamingRef) - // - // Pretty sure the game always resolves all links, but it might be not possible in our case. - // Those null checks are nauseating; ideally, we should have separate types for resolved and - // unresolved references so that we don't have to deal with nullability at all. - // - // Alternatively, we could make Ref an Optional-like type with methods like isPresent(), orElseThrow(), etc. - + protected Object readPointer(PointerTypeInfo info, BinaryReader reader, TypeFactory factory) throws IOException { if (!reader.readByteBoolean()) { return null; } else if (info.pointerType().equals("UUIDRef")) { @@ -198,7 +182,7 @@ private void resolveStreamingDataSource(StreamingDataSource dataSource) { } } - private Ref resolveLink(PointerTypeInfo info) { + private Object resolveLink(PointerTypeInfo info) { if (!resolveStreamingLinksAndLocators) { return null; } @@ -209,14 +193,15 @@ private Ref resolveLink(PointerTypeInfo info) { streamingLinkIndex = result.position(); - if (info.pointerType().equals("StreamingRef")) { - // If linkGroup != -1, then it's the id of the group; it's an equivalent of doing graph.group(linkGroup) + var pointerType = info.pointerType(); + if (pointerType.equals("StreamingRef")) { if (linkGroup != -1) { - return new StreamingRef<>(linkGroup, linkIndex); + // If linkGroup != -1, then it's the id of the group; it's an equivalent of doing graph.group(linkGroup) + return new StreamingRef<>(new ObjectId(linkGroup, linkIndex)); + } else { + // No idea how to resolve it otherwise. Presumably points to a runtime singleton? + return null; } - - // No idea how to resolve it otherwise. Likely relies on runtime - return null; } GroupResult group; @@ -247,7 +232,13 @@ private Ref resolveLink(PointerTypeInfo info) { throw new IllegalStateException("Type mismatch for pointer"); } - return new StreamingLink<>(object.object(), group.group().groupID(), linkIndex); + var objectId = new ObjectId(group.group().groupID(), linkIndex); + return switch (pointerType) { + case "Ref" -> new Ref<>(objectId, object.object()); + case "WeakPtr" -> new WeakPtr<>(objectId, object.object()); + case "cptr" -> new CPtr<>(objectId, object.object()); + default -> throw new UnsupportedOperationException("Unsupported pointer type: " + pointerType); + }; } private String indent() { diff --git a/odradek-game/src/main/java/sh/adelessfox/odradek/game/LinkProvider.java b/odradek-game/src/main/java/sh/adelessfox/odradek/game/LinkProvider.java new file mode 100644 index 00000000..bc192d14 --- /dev/null +++ b/odradek-game/src/main/java/sh/adelessfox/odradek/game/LinkProvider.java @@ -0,0 +1,16 @@ +package sh.adelessfox.odradek.game; + +import sh.adelessfox.odradek.rtti.data.TypePath; + +import java.io.Closeable; +import java.io.IOException; +import java.util.List; + +public interface LinkProvider extends Closeable { + record Link(int groupId, int objectIndex, TypePath path) { + } + + List getIncomingLinks(ObjectId target) throws IOException; + + List getOutgoingLinks(ObjectId source) throws IOException; +} diff --git a/odradek-game/src/main/java/sh/adelessfox/odradek/game/ObjectHolder.java b/odradek-game/src/main/java/sh/adelessfox/odradek/game/ObjectHolder.java index 94f474c9..4cfc73fd 100644 --- a/odradek-game/src/main/java/sh/adelessfox/odradek/game/ObjectHolder.java +++ b/odradek-game/src/main/java/sh/adelessfox/odradek/game/ObjectHolder.java @@ -1,14 +1,12 @@ package sh.adelessfox.odradek.game; -import sh.adelessfox.odradek.rtti.ClassTypeInfo; -import sh.adelessfox.odradek.rtti.data.TypedObject; +import java.util.function.Supplier; -import java.io.IOException; +public interface ObjectHolder extends ObjectIdHolder, Supplier { + T object(); -public interface ObjectHolder { - TypedObject readObject(Game game) throws IOException; - - ClassTypeInfo objectType(); - - String objectName(); + @Override + default T get() { + return object(); + } } diff --git a/odradek-game/src/main/java/sh/adelessfox/odradek/game/ObjectId.java b/odradek-game/src/main/java/sh/adelessfox/odradek/game/ObjectId.java index a4052dd1..0e793bc9 100644 --- a/odradek-game/src/main/java/sh/adelessfox/odradek/game/ObjectId.java +++ b/odradek-game/src/main/java/sh/adelessfox/odradek/game/ObjectId.java @@ -7,8 +7,8 @@ public static ObjectId valueOf(String value) { throw new IllegalArgumentException("Expected object id to be in form of groupId:objectIndex"); } return new ObjectId( - Integer.parseInt(parts[0]), - Integer.parseInt(parts[1]) + Integer.parseUnsignedInt(parts[0]), + Integer.parseUnsignedInt(parts[1]) ); } diff --git a/odradek-game/src/main/java/sh/adelessfox/odradek/game/ObjectSupplier.java b/odradek-game/src/main/java/sh/adelessfox/odradek/game/ObjectSupplier.java new file mode 100644 index 00000000..e6577650 --- /dev/null +++ b/odradek-game/src/main/java/sh/adelessfox/odradek/game/ObjectSupplier.java @@ -0,0 +1,12 @@ +package sh.adelessfox.odradek.game; + +import sh.adelessfox.odradek.rtti.ClassTypeInfo; +import sh.adelessfox.odradek.rtti.data.TypedObject; + +import java.io.IOException; + +public interface ObjectSupplier extends ObjectIdHolder { + TypedObject readObject(Game game) throws IOException; + + ClassTypeInfo objectType(); +} diff --git a/odradek-opengl-awt/pom.xml b/odradek-opengl-awt/pom.xml index 5c609e9b..437eb699 100644 --- a/odradek-opengl-awt/pom.xml +++ b/odradek-opengl-awt/pom.xml @@ -17,6 +17,10 @@ sh.adelessfox odradek-opengl + + sh.adelessfox + odradek-ui + diff --git a/odradek-opengl-awt/src/main/java/module-info.java b/odradek-opengl-awt/src/main/java/module-info.java index d0d19634..1e7fdcc9 100644 --- a/odradek-opengl-awt/src/main/java/module-info.java +++ b/odradek-opengl-awt/src/main/java/module-info.java @@ -1,6 +1,7 @@ module odradek.opengl.awt { requires java.desktop; requires odradek.opengl; + requires odradek.ui; requires org.lwjgl.jawt; requires org.lwjgl.opengl; requires org.lwjgl; diff --git a/odradek-opengl-awt/src/main/java/sh/adelessfox/odradek/opengl/awt/GLCanvas.java b/odradek-opengl-awt/src/main/java/sh/adelessfox/odradek/opengl/awt/GLCanvas.java index 7bccf85b..fddb3a10 100644 --- a/odradek-opengl-awt/src/main/java/sh/adelessfox/odradek/opengl/awt/GLCanvas.java +++ b/odradek-opengl-awt/src/main/java/sh/adelessfox/odradek/opengl/awt/GLCanvas.java @@ -5,6 +5,7 @@ import org.lwjgl.system.Platform; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sh.adelessfox.odradek.ui.util.GraphicsUtils; import javax.swing.*; import java.awt.*; @@ -77,36 +78,14 @@ public void removeNotify() { @Override public void paint(Graphics g) { if (canvas == null) { - Graphics2D g2 = (Graphics2D) g.create(); - g2.clearRect(0, 0, getWidth(), getHeight()); - g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); - drawCenteredString(g2, "Unable to create graphics context; refer to logs for more details", getWidth(), getHeight()); - g2.dispose(); + g.clearRect(0, 0, getWidth(), getHeight()); + GraphicsUtils.setTextRenderingHints(g); + GraphicsUtils.drawCenteredString(g, "Unable to create graphics context; refer to logs for more details", this); } else { render(); } } - private static void drawCenteredString(Graphics g, String text, int width, int height) { - FontMetrics fm = g.getFontMetrics(); - - int x = 0; - int y = (height - fm.getHeight() + 1) / 2 + fm.getAscent(); - - if (text.indexOf('\n') >= 0) { - for (String line : text.split("\n")) { - drawCenteredString(g, line, x, y, width); - y += fm.getHeight(); - } - } else { - drawCenteredString(g, text, x, y, width); - } - } - - private static void drawCenteredString(Graphics g, String text, int x, int y, int width) { - g.drawString(text, x + (width - g.getFontMetrics().stringWidth(text)) / 2, y); - } - @Override public void update(Graphics g) { // This override makes so that the canvas doesn't flicker by removing super's clearRect @@ -176,7 +155,7 @@ private void submit(Runnable runnable) { runnable.run(); } finally { - //noinspection DataFlowIssue - false positive + // noinspection DataFlowIssue - false positive GL.setCapabilities(null); canvas.makeCurrent(0); diff --git a/odradek-rtti-generator/src/main/java/sh/adelessfox/odradek/rtti/generator/source/TypeSourceGenerator.java b/odradek-rtti-generator/src/main/java/sh/adelessfox/odradek/rtti/generator/source/TypeSourceGenerator.java index 66aa3456..9796cca3 100644 --- a/odradek-rtti-generator/src/main/java/sh/adelessfox/odradek/rtti/generator/source/TypeSourceGenerator.java +++ b/odradek-rtti-generator/src/main/java/sh/adelessfox/odradek/rtti/generator/source/TypeSourceGenerator.java @@ -6,7 +6,6 @@ import sh.adelessfox.odradek.io.BinaryReader; import sh.adelessfox.odradek.rtti.*; import sh.adelessfox.odradek.rtti.data.ExtraBinaryDataHolder; -import sh.adelessfox.odradek.rtti.data.Ref; import sh.adelessfox.odradek.rtti.data.TypedObject; import sh.adelessfox.odradek.rtti.data.Value; import sh.adelessfox.odradek.rtti.factory.TypeFactory; @@ -23,7 +22,6 @@ final class TypeSourceGenerator extends TypeGenerator { private static final Logger log = LoggerFactory.getLogger(TypeSourceGenerator.class); private static final ClassName NAME_List = ClassName.get(List.class); - private static final ClassName NAME_Ref = ClassName.get(Ref.class); private static final ClassName NAME_Value_OfEnum = ClassName.get(Value.OfEnum.class); private static final ClassName NAME_Value_OfEnumSet = ClassName.get(Value.OfEnumSet.class); @@ -246,7 +244,11 @@ private TypeName toJavaType(TypeInfo info) { } case PointerTypeInfo i -> { var name = toJavaType(i.itemType()).box(); - yield ParameterizedTypeName.get(NAME_Ref, name); + var type = getBuiltin(i.pointerType()) + .map(TypeName::get).map(ClassName.class::cast) + .orElseThrow(() -> new IllegalStateException("Builtin for pointer type '" + i.pointerType() + "' not found")); + + yield ParameterizedTypeName.get(type, name); } }; } diff --git a/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/data/Ref.java b/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/data/Ref.java deleted file mode 100644 index aef2a725..00000000 --- a/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/data/Ref.java +++ /dev/null @@ -1,26 +0,0 @@ -package sh.adelessfox.odradek.rtti.data; - -import java.util.Iterator; - -@FunctionalInterface -public interface Ref { - T get(); - - static Iterable unwrap(Iterable> iterable) { - return () -> unwrap(iterable.iterator()); - } - - static Iterator unwrap(Iterator> iterator) { - return new Iterator<>() { - @Override - public boolean hasNext() { - return iterator.hasNext(); - } - - @Override - public T next() { - return iterator.next().get(); - } - }; - } -} diff --git a/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/data/TypePath.java b/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/data/TypePath.java new file mode 100644 index 00000000..8be92a7a --- /dev/null +++ b/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/data/TypePath.java @@ -0,0 +1,64 @@ +package sh.adelessfox.odradek.rtti.data; + +import sh.adelessfox.odradek.rtti.ClassAttrInfo; +import sh.adelessfox.odradek.rtti.ClassTypeInfo; +import sh.adelessfox.odradek.rtti.ContainerTypeInfo; + +import java.util.ArrayList; +import java.util.List; + +public record TypePath(List elements) { + public sealed interface Element { + record Attr(ClassTypeInfo type, ClassAttrInfo attr) implements Element { + } + + record Index(ContainerTypeInfo type, int index) implements Element { + } + } + + public TypePath { + if (elements.isEmpty()) { + throw new IllegalArgumentException("Path cannot be empty"); + } + elements = List.copyOf(elements); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public String toString() { + var buf = new StringBuilder("o"); + for (Element element : elements) { + switch (element) { + case Element.Attr(_, var attr) -> buf.append('.').append(attr.name()); + case Element.Index(_, int index) -> buf.append('[').append(index).append(']'); + } + } + return buf.toString(); + } + + public static final class Builder { + private final List elements = new ArrayList<>(); + + private Builder() { + } + + public void attr(ClassTypeInfo type, ClassAttrInfo attr) { + elements.add(new Element.Attr(type, attr)); + } + + public void index(ContainerTypeInfo type, int index) { + elements.add(new Element.Index(type, index)); + } + + public void pop() { + elements.removeLast(); + } + + public TypePath build() { + return new TypePath(elements); + } + } +} diff --git a/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/data/TypeVisitor.java b/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/data/TypeVisitor.java new file mode 100644 index 00000000..a9000aaa --- /dev/null +++ b/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/data/TypeVisitor.java @@ -0,0 +1,50 @@ +package sh.adelessfox.odradek.rtti.data; + +import sh.adelessfox.odradek.rtti.*; + +public class TypeVisitor { + protected void visitAtom(AtomTypeInfo info, Object object, TypePath.Builder builder) { + // do nothing + } + + protected void visitClass(ClassTypeInfo info, Object object, TypePath.Builder builder) { + for (ClassAttrInfo attr : info.orderedAttrs()) { + builder.attr(info, attr); + visit(attr.type(), info.get(attr, object), builder); + builder.pop(); + } + } + + protected void visitContainer(ContainerTypeInfo info, Object object, TypePath.Builder builder) { + for (int i = 0, length = info.length(object); i < length; i++) { + builder.index(info, i); + visit(info.itemType(), info.get(object, i), builder); + builder.pop(); + } + } + + protected void visitEnum(EnumTypeInfo info, Value object, TypePath.Builder builder) { + // do nothing + } + + protected void visitPointer(PointerTypeInfo info, Object object, TypePath.Builder builder) { + // do nothing + } + + public void visit(TypeInfo info, Object object) { + visit(info, object, TypePath.builder()); + } + + public void visit(TypeInfo info, Object object, TypePath.Builder builder) { + if (object == null) { + return; + } + switch (info) { + case AtomTypeInfo i -> visitAtom(i, object, builder); + case ClassTypeInfo i -> visitClass(i, object, builder); + case ContainerTypeInfo i -> visitContainer(i, object, builder); + case EnumTypeInfo i -> visitEnum(i, (Value) object, builder); + case PointerTypeInfo i -> visitPointer(i, object, builder); + } + } +} diff --git a/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/generator/TypeRuntimeGenerator.java b/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/generator/TypeRuntimeGenerator.java index 4b40b5c2..c9cf1e25 100644 --- a/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/generator/TypeRuntimeGenerator.java +++ b/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/generator/TypeRuntimeGenerator.java @@ -1,7 +1,6 @@ package sh.adelessfox.odradek.rtti.generator; import sh.adelessfox.odradek.rtti.*; -import sh.adelessfox.odradek.rtti.data.Ref; import sh.adelessfox.odradek.rtti.data.Value; import java.lang.classfile.*; @@ -22,7 +21,6 @@ public final class TypeRuntimeGenerator extends TypeGenerator> { private static final ClassDesc CD_Arrays = Arrays.class.describeConstable().orElseThrow(); private static final ClassDesc CD_ClassTypeInfo = ClassTypeInfo.class.describeConstable().orElseThrow(); private static final ClassDesc CD_List = List.class.describeConstable().orElseThrow(); - private static final ClassDesc CD_Ref = Ref.class.describeConstable().orElseThrow(); private static final ClassDesc CD_StableValue = StableValue.class.describeConstable().orElseThrow(); private static final ClassDesc CD_TypeDescriptor = TypeDescriptor.class.describeConstable().orElseThrow(); private static final ClassDesc CD_UnsupportedOperationException = UnsupportedOperationException.class.describeConstable().orElseThrow(); @@ -438,7 +436,12 @@ private ClassDesc toClassDesc(TypeInfo info, boolean useWrapperType) { case ClassTypeInfo i -> toClassDesc(i); case EnumSetTypeInfo i -> useWrapperType ? CD_Value_OfEnumSet : toClassDesc(i); case EnumTypeInfo i -> useWrapperType ? CD_Value_OfEnum : toClassDesc(i); - case AtomTypeInfo i -> getBuiltin(i.base().name()).flatMap(Class::describeConstable).orElseThrow(); + case AtomTypeInfo i -> getBuiltin(i.base().name()) + .flatMap(Class::describeConstable) + .orElseThrow(); + case PointerTypeInfo i -> getBuiltin(i.pointerType()) + .flatMap(Class::describeConstable) + .orElseThrow(); case ContainerTypeInfo i -> { var itemType = toClassDesc(i.itemType(), useWrapperType); if (itemType.isPrimitive()) { @@ -447,7 +450,6 @@ private ClassDesc toClassDesc(TypeInfo info, boolean useWrapperType) { yield CD_List; } } - case PointerTypeInfo _ -> CD_Ref; }; } diff --git a/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/io/AbstractTypeReader.java b/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/io/AbstractTypeReader.java index 5c38ffdc..e0008cff 100644 --- a/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/io/AbstractTypeReader.java +++ b/odradek-rtti/src/main/java/sh/adelessfox/odradek/rtti/io/AbstractTypeReader.java @@ -3,7 +3,6 @@ import sh.adelessfox.odradek.io.BinaryReader; import sh.adelessfox.odradek.rtti.*; import sh.adelessfox.odradek.rtti.data.ExtraBinaryDataHolder; -import sh.adelessfox.odradek.rtti.data.Ref; import sh.adelessfox.odradek.rtti.data.Value; import sh.adelessfox.odradek.rtti.factory.TypeFactory; @@ -49,5 +48,5 @@ protected void fillCompound(ClassTypeInfo info, BinaryReader reader, TypeFactory protected abstract Object readContainer(ContainerTypeInfo info, BinaryReader reader, TypeFactory factory) throws IOException; - protected abstract Ref readPointer(PointerTypeInfo info, BinaryReader reader, TypeFactory factory) throws IOException; + protected abstract Object readPointer(PointerTypeInfo info, BinaryReader reader, TypeFactory factory) throws IOException; } diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/StyledComponent.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/StyledComponent.java index 4b076383..98920ebf 100644 --- a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/StyledComponent.java +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/StyledComponent.java @@ -1,8 +1,8 @@ package sh.adelessfox.odradek.ui.components; -import com.formdev.flatlaf.ui.FlatUIUtils; import com.formdev.flatlaf.util.ColorFunctions; import com.formdev.flatlaf.util.UIScale; +import sh.adelessfox.odradek.ui.util.GraphicsUtils; import javax.swing.*; import java.awt.*; @@ -169,8 +169,7 @@ private void doPaintIconBackground(Graphics2D g, Icon icon, int offset, int padd } private int doPaintTextFragments(Graphics2D g, int startOffset) { - FlatUIUtils.setRenderingHints(g); - setTextRenderingHints(g); + GraphicsUtils.setTextRenderingHints(g); float offset = startOffset; @@ -183,10 +182,9 @@ private int doPaintTextFragments(Graphics2D g, int startOffset) { synchronized (fragments) { for (StyledFragment fragment : fragments) { - FontMetrics metrics = getFontMetrics(font); - - var fragmentBaseline = area.y + area.height - metrics.getDescent(); - var fragmentWidth = computeFragmentWidth(fragment, font); + var fragmentFont = computeFragmentFont(fragment, font); + var fragmentBaseline = area.y + area.height - getFontMetrics(fragmentFont).getDescent(); + var fragmentWidth = computeFragmentWidth(fragment, fragmentFont); var fragmentColor = computeFragmentForeground(fragment); if (DEBUG_OVERLAY) { @@ -194,7 +192,7 @@ private int doPaintTextFragments(Graphics2D g, int startOffset) { g.drawRect((int) offset, area.y, (int) fragmentWidth - 1, area.height - 1); } - g.setFont(font); + g.setFont(fragmentFont); g.setColor(fragmentColor); g.drawString(fragment.text(), offset, fragmentBaseline); @@ -205,24 +203,6 @@ private int doPaintTextFragments(Graphics2D g, int startOffset) { return (int) offset - startOffset; } - private static void setTextRenderingHints(Graphics2D g2) { - Object aaHint = UIManager.get(RenderingHints.KEY_TEXT_ANTIALIASING); - if (aaHint != null) { - Object oldAA = g2.getRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING); - if (aaHint != oldAA) { - g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, aaHint); - } - } - - Object contrastHint = UIManager.get(RenderingHints.KEY_TEXT_LCD_CONTRAST); - if (contrastHint != null) { - Object oldContrast = g2.getRenderingHint(RenderingHints.KEY_TEXT_LCD_CONTRAST); - if (contrastHint != oldContrast) { - g2.setRenderingHint(RenderingHints.KEY_TEXT_LCD_CONTRAST, contrastHint); - } - } - } - private void doPaintTextBackground(Graphics2D g, int offset) { if (isOpaque()) { g.setColor(getBackground()); @@ -267,6 +247,20 @@ private Color computeFragmentForeground(StyledFragment fragment) { } } + private Font computeFragmentFont(StyledFragment fragment, Font font) { + int style = Font.PLAIN; + if (fragment.flags().contains(StyledFlag.BOLD)) { + style |= Font.BOLD; + } + if (fragment.flags().contains(StyledFlag.ITALIC)) { + style |= Font.ITALIC; + } + if (font.getStyle() == style) { + return font; + } + return font.deriveFont(style); + } + private float computeFragmentWidth(StyledFragment fragment, Font font) { var metrics = getFontMetrics(font); var bounds = font.getStringBounds(fragment.text(), metrics.getFontRenderContext()); @@ -284,7 +278,7 @@ private float computePreferredWidth() { synchronized (fragments) { for (StyledFragment fragment : fragments) { - width += computeFragmentWidth(fragment, font); + width += computeFragmentWidth(fragment, computeFragmentFont(fragment, font)); } } diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/tool/ToolPanelContainer.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/tool/ToolPanelContainer.java index 21b4c96b..5fbe0498 100644 --- a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/tool/ToolPanelContainer.java +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/tool/ToolPanelContainer.java @@ -8,7 +8,9 @@ import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * A panel with buttons on either sides that reveal contents when clicked. @@ -25,6 +27,8 @@ public final class ToolPanelContainer extends JComponent { private final ToolPanelGroup secondaryGroup = new ToolPanelGroup(); private final Placement placement; + private final Map panels = new HashMap<>(); + public enum Placement { LEFT, RIGHT @@ -58,15 +62,19 @@ public void setContent(JComponent content) { } } - public void addPrimaryPanel(String text, Icon icon, ToolPanel panel) { - addPanel(text, icon, panel, true); + public void addPrimaryPanel(String id, String text, Icon icon, ToolPanel panel) { + addPanel(id, text, icon, panel, true); } - public void addSecondaryPanel(String text, Icon icon, ToolPanel panel) { - addPanel(text, icon, panel, false); + public void addSecondaryPanel(String id, String text, Icon icon, ToolPanel panel) { + addPanel(id, text, icon, panel, false); } - private void addPanel(String text, Icon icon, ToolPanel panel, boolean primary) { + private void addPanel(String id, String text, Icon icon, ToolPanel panel, boolean primary) { + if (panels.containsKey(id)) { + throw new IllegalArgumentException("Panel with id '" + id + "' already exists"); + } + var panelGroup = primary ? primaryGroup : secondaryGroup; panelGroup.addPanel(panel); @@ -85,14 +93,24 @@ private void addPanel(String text, Icon icon, ToolPanel panel, boolean primary) if (buttonsPanel.getComponentCount() != separatorIndex) { buttonsPanel.add(new JSeparator(), "growx", separatorIndex); } + + panels.put(id, panel); } - public void showPanel(ToolPanel panel) { - selectPanel(panel, true); + public void showPanel(String id) { + selectPanel(id, true); } - public void hidePanel(ToolPanel panel) { - selectPanel(panel, false); + public void hidePanel(String id) { + selectPanel(id, false); + } + + private void selectPanel(String id, boolean select) { + var panel = panels.get(id); + if (panel == null) { + throw new IllegalArgumentException("No panel with id '" + id + "' found"); + } + selectPanel(panel, select); } private void selectPanel(ToolPanel panel, boolean select) { diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/tree/StructuredTree.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/tree/StructuredTree.java index b2af8246..f407b778 100644 --- a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/tree/StructuredTree.java +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/tree/StructuredTree.java @@ -5,6 +5,7 @@ import sh.adelessfox.odradek.ui.components.StyledTreeCellRenderer; import sh.adelessfox.odradek.ui.data.DataContext; import sh.adelessfox.odradek.ui.data.DataKeys; +import sh.adelessfox.odradek.ui.util.GraphicsUtils; import sh.adelessfox.odradek.ui.util.Listeners; import javax.swing.*; @@ -25,14 +26,23 @@ public class StructuredTree> extends JTree implements DataContext { private final Listeners actionListeners = new Listeners<>(TreeActionListener.class); private TreeLabelProvider labelProvider; + private String placeholderText; // For caching last shown tooltip while hovering over the same row private int lastRowIndex = -1; private int lastRowCount = -1; + public StructuredTree() { + super((TreeModel) null); + setup(); + } + public StructuredTree(TreeStructure structure) { super(new StructuredTreeModel<>(structure)); + setup(); + } + private void setup() { addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { @@ -62,6 +72,12 @@ public void keyPressed(KeyEvent e) { ToolTipManager.sharedInstance().registerComponent(this); } + public void expand() { + for (int i = 0; i < getRowCount(); i++) { + expandRow(i); + } + } + @Override public Optional get(String key) { if (DataKeys.COMPONENT.is(key)) { @@ -126,6 +142,16 @@ public String getToolTipText(MouseEvent event) { return super.getToolTipText(event); } + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + + if (placeholderText != null && getModel() == null || getModel().isEmpty()) { + GraphicsUtils.setTextRenderingHints(g); + GraphicsUtils.drawCenteredString(g, placeholderText, this); + } + } + @Override @SuppressWarnings("unchecked") public StructuredTreeModel getModel() { @@ -170,6 +196,17 @@ public void removeActionListener(TreeActionListener listener) { actionListeners.remove(listener); } + public String getPlaceholderText() { + return placeholderText; + } + + public void setPlaceholderText(String placeholderText) { + if (!Objects.equals(this.placeholderText, placeholderText)) { + this.placeholderText = placeholderText; + repaint(); + } + } + public Object getSelectionPathComponent() { return getSelectionComponent(getSelectionPath()); } diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/tree/StructuredTreeModel.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/tree/StructuredTreeModel.java index 636e1e90..b164470f 100644 --- a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/tree/StructuredTreeModel.java +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/tree/StructuredTreeModel.java @@ -94,6 +94,10 @@ public void setFilter(Predicate filter) { this.filter = filter; } + boolean isEmpty() { + return rootNode == null || getChildCount(rootNode) == 0; + } + /** * Refreshes the entire tree, recomputing the children of each node */ diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/renderers/PointerRenderer.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/renderers/PointerRenderer.java index ee9cab0e..02e10fa1 100644 --- a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/renderers/PointerRenderer.java +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/renderers/PointerRenderer.java @@ -1,18 +1,19 @@ package sh.adelessfox.odradek.ui.renderers; import sh.adelessfox.odradek.game.Game; +import sh.adelessfox.odradek.game.ObjectHolder; +import sh.adelessfox.odradek.rtti.PointerTypeInfo; import sh.adelessfox.odradek.rtti.TypeInfo; -import sh.adelessfox.odradek.rtti.data.Ref; import sh.adelessfox.odradek.rtti.data.TypedObject; import sh.adelessfox.odradek.ui.Renderer; import sh.adelessfox.odradek.ui.components.StyledText; import java.util.Optional; -public class PointerRenderer implements Renderer.OfObject, Game> { +public class PointerRenderer implements Renderer.OfObject { @Override - public Optional styledText(TypeInfo info, Ref object, Game game) { - if (object != null && object.get() instanceof TypedObject to) { + public Optional styledText(TypeInfo info, Object object, Game game) { + if (object instanceof ObjectHolder holder && holder.object() instanceof TypedObject to) { return Renderer.renderer(to.getType()) .flatMap(x -> x.styledText(to.getType(), to, game)); } @@ -20,15 +21,20 @@ public Optional styledText(TypeInfo info, Ref object, Game game) } @Override - public Optional text(TypeInfo info, Ref object, Game game) { + public Optional text(TypeInfo info, Object object, Game game) { if (object == null) { return Optional.of("null"); } - if (object.get() instanceof TypedObject to) { + if (object instanceof ObjectHolder holder && holder.object() instanceof TypedObject to) { return Renderer.renderer(to.getType()) .flatMap(x -> x.text(to.getType(), to, game)) .or(() -> Optional.of("<%s>".formatted(to.getType().name()))); } return Optional.of(object.toString()); } + + @Override + public boolean supports(TypeInfo info) { + return info instanceof PointerTypeInfo; + } } diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/util/GraphicsUtils.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/util/GraphicsUtils.java new file mode 100644 index 00000000..7a93f0ae --- /dev/null +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/util/GraphicsUtils.java @@ -0,0 +1,39 @@ +package sh.adelessfox.odradek.ui.util; + +import javax.swing.*; +import java.awt.*; + +public final class GraphicsUtils { + private GraphicsUtils() { + } + + public static void drawCenteredString(Graphics g, String text, Component c) { + drawCenteredString(g, text, c.getWidth(), c.getHeight()); + } + + public static void drawCenteredString(Graphics g, String text, int width, int height) { + var fm = g.getFontMetrics(); + var lines = text.split("\n"); + + int x = 0; + int y = (height - fm.getHeight() * lines.length + 1) / 2 + fm.getAscent(); + for (String line : lines) { + g.drawString(line, x + (width - fm.stringWidth(line)) / 2, y); + y += fm.getHeight(); + } + } + + public static void setTextRenderingHints(Graphics g) { + var g2 = (Graphics2D) g; + + var hint1 = UIManager.get(RenderingHints.KEY_TEXT_ANTIALIASING); + if (hint1 != null) { + g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, hint1); + } + + var hint2 = UIManager.get(RenderingHints.KEY_TEXT_LCD_CONTRAST); + if (hint2 != null) { + g2.setRenderingHint(RenderingHints.KEY_TEXT_LCD_CONTRAST, hint2); + } + } +}