package market.guess.ui.desktop.controllers; import java.math.BigDecimal; import java.nio.file.Paths; import javafx.animation.FadeTransition; import javafx.animation.PauseTransition; import javafx.beans.binding.Bindings; import javafx.collections.ListChangeListener; import javafx.css.PseudoClass; import javafx.fxml.FXML; import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; import javafx.scene.control.TabPane; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.stage.FileChooser; import javafx.stage.FileChooser.ExtensionFilter; import javafx.util.Duration; import market.guess.model.event.CreateEventRequest; import market.guess.model.event.EventStatus; import market.guess.model.event.MechanismType; import market.guess.ui.desktop.AppState; import market.guess.ui.desktop.AppView; import market.guess.ui.desktop.Skin; import market.guess.ui.desktop.components.AppTab; import market.guess.ui.desktop.components.ModalDialog; import market.guess.ui.desktop.components.graphic.AnimationToggleGraphic; import market.guess.ui.desktop.components.graphic.Graphic; import market.guess.ui.desktop.model.EventData; import market.guess.ui.desktop.model.UserData; import market.guess.ui.desktop.task.InitialLoadTask; import market.guess.ui.desktop.util.Format; import market.guess.ui.desktop.util.Views; public class AppController { private static final PseudoClass SKIN_ROSE_PINE = PseudoClass.getPseudoClass("skin-rose-pine-dawn"); private static final PseudoClass SKIN_CATPPUCCIN = PseudoClass.getPseudoClass("skin-catppuccin"); private static final PseudoClass SKIN_GRUVBOX = PseudoClass.getPseudoClass("skin-gruvbox"); private static final Duration TOAST_DURATION = Duration.millis(2500); private static final Duration FADE_AWAY_DURATION = Duration.millis(500); private AppView appView; private double dragOffsetX; private double dragOffsetY; private InitialLoadTask currentLoadTask; private Graphic animationsGraphic; @FXML private StackPane rootStack; @FXML private BorderPane chrome; @FXML private HBox windowBar; @FXML private TabPane tabPane; @FXML private HBox tabInfo; @FXML private Label loadedFilePathLabel; @FXML private ComboBox skinComboBox; @FXML private Button animationsButton; @FXML private Label balanceValueLabel; @FXML private Label actingAsLabel; @FXML private StackPane centerContainer; @FXML private Label statusBarLabel; @FXML private Button ejectButton; @FXML private StackPane dialogContainer; @FXML private StackPane toastContainer; @FXML private Label toastLabel; // Injected by FXMLLoader from app_view.fxml via @FXML private Node emptyState; @FXML private EmptyStateController emptyStateController; @FXML private Node eventsTab; @FXML private EventsTabController eventsTabController; @FXML private Node usersTab; @FXML private UsersTabController usersTabController; @FXML private Node loadDialog; @FXML private LoadDialogController loadDialogController; @FXML private Node createEventDialog; @FXML private CreateEventDialogController createEventDialogController; @FXML private Node resolveDialog; @FXML private ResolveDialogController resolveDialogController; private Node currentlyActiveContent; @FXML private void initialize() { loadDialogController.setOnCancel(this::cancelLoad); createEventDialogController.setOnCancel(this::closeDialog); createEventDialogController.setOnCreate((CreateEventRequest req) -> createEvent(req)); resolveDialogController.setOnCancel(this::closeDialog); resolveDialogController.setOnConfirm(this::resolveEvent); } public void init(AppView appView) { this.appView = appView; setupWindowDrag(); setupSkinComboBox(); setupAnimationsButton(); initSubViews(); setupStateBindings(); applyInitialState(); } private void setupAnimationsButton() { animationsGraphic = AnimationToggleGraphic.create(); if (animationsButton != null) { animationsButton.setGraphic(animationsGraphic.getNode()); } } private void setupWindowDrag() { windowBar.setOnMousePressed( e -> { dragOffsetX = e.getSceneX(); dragOffsetY = e.getSceneY(); }); windowBar.setOnMouseDragged( e -> { appView.getStage().setX(e.getScreenX() - dragOffsetX); appView.getStage().setY(e.getScreenY() - dragOffsetY); }); windowBar.setOnMouseClicked( e -> { if (e.getClickCount() == 2) { var stage = appView.getStage(); stage.setMaximized(!stage.isMaximized()); } }); } private void setupSkinComboBox() { skinComboBox.getItems().setAll("Rosé Pine Dawn", "Catppuccin", "Gruvbox"); skinComboBox.setValue("Rosé Pine Dawn"); } private void initSubViews() { var state = getState(); eventsTabController.init(state); eventsTabController.setOnNewEvent(this::openCreateEventDialog); eventsTabController.setOnOpenEvent(this::openEvent); eventsTabController.setOnCloseEvent(this::openResolveDialog); usersTabController.init(state); usersTabController.setOnCreateNewEvent(this::openCreateEventDialog); usersTabController.setOnToast(this::toast); usersTabController.setOnPlaceOrder(this::placeOrder); } private void setupStateBindings() { var state = appView.getState(); // Declarative property bindings loadedFilePathLabel .textProperty() .bind( Bindings.when( state.loadedFileProperty().isNull().or(state.loadedFileProperty().isEmpty())) .then("") .otherwise(state.loadedFileProperty())); ejectButton.visibleProperty().bind(state.loadedFileProperty().isNotNull()); ejectButton.managedProperty().bind(ejectButton.visibleProperty()); tabInfo.visibleProperty().bind(tabPane.visibleProperty()); // The header area only exists once the skin is installed; match its height so the // overlaid acting-as/balance labels stay vertically centred in it. tabPane .skinProperty() .addListener( (obs, oldV, newV) -> { var header = (Region) tabPane.lookup(".tab-header-area"); tabInfo.prefHeightProperty().bind(header.heightProperty()); }); tabPane .getSelectionModel() .selectedIndexProperty() .addListener((obs, oldV, newV) -> state.setActiveTab(AppTab.values()[newV.intValue()])); // Granular reactive listeners state.skinProperty().addListener((obs, oldV, newV) -> updateSkin(newV)); state .loadedFileProperty() .addListener( (obs, oldV, newV) -> { updateCenterContent(); updateStatusBar(); }); state.activeTabProperty().addListener((obs, oldV, newV) -> updateTabs(newV)); state.dialogProperty().addListener((obs, oldV, newV) -> updateDialogOverlay()); state.toastProperty().addListener((obs, oldV, newV) -> updateToastOverlay()); state.animationsOnProperty().addListener((obs, oldV, newV) -> updateAnimations(newV)); state.actingUserProperty().addListener((obs, oldV, newV) -> updateActingUserDisplay()); state.actingUserNameProperty().addListener((obs, oldV, newV) -> updateActingUserDisplay()); state.getEvents().addListener((ListChangeListener) c -> updateStatusBar()); state.getUsers().addListener((ListChangeListener) c -> updateStatusBar()); } public AppState getState() { return appView != null ? appView.getState() : null; } /** * Refreshes UI state across all components. Kept for lifecycle compatibility; delegates to * targeted update methods. */ public void refresh() { applyInitialState(); } private void applyInitialState() { var state = getState(); if (state == null) return; updateSkin(state.getSkin()); updateAnimations(state.isAnimationsOn()); updateTabs(state.getActiveTab()); updateActingUserDisplay(); updateCenterContent(); updateStatusBar(); updateDialogOverlay(); updateToastOverlay(); } private void updateSkin(Skin skin) { if (skin == null) return; rootStack.pseudoClassStateChanged(SKIN_ROSE_PINE, skin == Skin.ROSE_PINE_DAWN); rootStack.pseudoClassStateChanged(SKIN_CATPPUCCIN, skin == Skin.CATPPUCCIN); rootStack.pseudoClassStateChanged(SKIN_GRUVBOX, skin == Skin.GRUVBOX); String comboVal = switch (skin) { case CATPPUCCIN -> "Catppuccin"; case GRUVBOX -> "Gruvbox"; default -> "Rosé Pine Dawn"; }; if (!comboVal.equals(skinComboBox.getValue())) { skinComboBox.setValue(comboVal); } } private void updateAnimations(boolean animOn) { Views.setSelected(animationsButton, animOn); if (animationsGraphic != null) { animationsGraphic.setPlaying(animOn); } if (emptyStateController != null) { emptyStateController.setAnimating(animOn); } } private void updateTabs(AppTab tab) { // ponytail: tab order in app_view.fxml must match AppTab declaration order tabPane.getSelectionModel().select(tab.ordinal()); updateCenterContent(); } private void updateActingUserDisplay() { var state = getState(); if (state == null) return; var actor = state.getActingUser(); if (actor == null) return; balanceValueLabel.setText(Format.money(actor.balance)); actingAsLabel.setText( "acting as " + (state.getActingUserName() == null ? "" : state.getActingUserName())); } private void updateCenterContent() { var state = getState(); if (state == null) return; boolean showEmpty = (state.getLoadedFile() == null); boolean showEvents = !showEmpty && (state.getActiveTab() == AppTab.EVENTS); boolean showUsers = !showEmpty && (state.getActiveTab() == AppTab.USERS); Node activeContent = showEmpty ? emptyState : (showEvents ? eventsTab : usersTab); emptyState.setVisible(showEmpty); emptyState.setManaged(showEmpty); tabPane.setVisible(!showEmpty); tabPane.setManaged(!showEmpty); if (showEvents && eventsTabController != null) { eventsTabController.refresh(); } if (showUsers && usersTabController != null) { usersTabController.refresh(); } if (currentlyActiveContent != activeContent) { Node prev = currentlyActiveContent; currentlyActiveContent = activeContent; if (state.isAnimationsOn() && state.getLoadedFile() != null && prev != null) { var fade = new FadeTransition(FADE_AWAY_DURATION, activeContent); fade.setFromValue(0); fade.setToValue(1); fade.play(); } } } private void updateStatusBar() { var state = getState(); if (state == null || statusBarLabel == null) return; if (state.getLoadedFile() == null) { statusBarLabel.setText(" IDLE  "); } else { String fileName = Paths.get(state.getLoadedFile()).getFileName().toString(); statusBarLabel.setText( "  " + fileName + "   " + state.getEvents().size() + " events   " + state.getUsers().size() + " users"); } } private void updateDialogOverlay() { var state = getState(); if (state == null) return; boolean showDialog = (state.getDialog() != ModalDialog.NONE); dialogContainer.setVisible(showDialog); dialogContainer.setMouseTransparent(!showDialog); boolean isLoad = (state.getDialog() == ModalDialog.LOAD); loadDialog.setVisible(isLoad); loadDialog.setManaged(isLoad); boolean isCreate = (state.getDialog() == ModalDialog.CREATE_EVENT); createEventDialog.setVisible(isCreate); createEventDialog.setManaged(isCreate); boolean isResolve = (state.getDialog() == ModalDialog.RESOLVE); resolveDialog.setVisible(isResolve); resolveDialog.setManaged(isResolve); } private void updateToastOverlay() { var state = getState(); if (state == null) return; if (state.getToast() != null) { toastLabel.setText(state.getToast()); toastLabel.setVisible(true); } else { toastLabel.setVisible(false); } } // ---- FXML action handlers ---------------------------------------------- @FXML private void handleClose() { appView.getStage().close(); } @FXML private void handleMinimize() { appView.getStage().setIconified(true); } @FXML private void handleMaximize() { var stage = appView.getStage(); stage.setMaximized(!stage.isMaximized()); } @FXML private void handleSkinChanged() { String val = skinComboBox.getValue(); if (val == null) return; Skin skin = switch (val) { case "Catppuccin" -> Skin.CATPPUCCIN; case "Gruvbox" -> Skin.GRUVBOX; default -> Skin.ROSE_PINE_DAWN; }; var state = appView.getState(); if (state != null && state.getSkin() != skin) { state.setSkin(skin); } } @FXML private void handleAnimationsToggled() { appView.getState().setAnimationsOn(!appView.getState().isAnimationsOn()); } @FXML private void handleLoadFile() { startLoad(); } @FXML private void handleEjectFile() { appView.getState().setLoadedFile(null); } // ---- Public Actions --------------------------------------------------- public void toast(String msg) { appView.getState().setToast(msg); var pause = new PauseTransition(TOAST_DURATION); pause.setOnFinished(e -> appView.getState().setToast(null)); pause.play(); } public void startLoad() { var stage = appView.getStage(); var fc = new FileChooser(); fc.setTitle("Load events file"); fc.getExtensionFilters().add(new ExtensionFilter("GuessMarket Files", "*.xml")); var picked = stage.getScene() == null ? null : fc.showOpenDialog(stage); if (picked == null) { return; } var state = appView.getState(); state.setPendingFile(picked.getAbsolutePath()); var loadTask = new InitialLoadTask(appView.getCatalogContext(), Paths.get(state.getPendingFile())); this.currentLoadTask = loadTask; loadTask.setOnRunning( e -> { state.setDialog(ModalDialog.LOAD); loadDialogController.show(loadTask, state.getPendingFile()); }); loadTask.setOnCancelled( e -> { this.currentLoadTask = null; loadDialogController.reset(); state.setDialog(ModalDialog.NONE); }); loadTask.setOnFailed( e -> { this.currentLoadTask = null; loadDialogController.reset(); cancelLoad(); Throwable ex = loadTask.getException(); String msg = (ex != null && ex.getMessage() != null && !ex.getMessage().isBlank()) ? ex.getMessage() : "Failed to load file."; toast(msg); }); loadTask.setOnSucceeded( e -> { this.currentLoadTask = null; loadDialogController.reset(); if (state.getDialog() != ModalDialog.LOAD) return; var loadResult = loadTask.getValue(); state.setLoadedFile(loadResult.source()); state.refreshData(); state.setDialog(ModalDialog.NONE); toast( "File loaded  " + loadResult.eventsLoaded() + " events, " + state.getUsers().size() + " users"); }); var thread = new Thread(loadTask); thread.setDaemon(true); thread.start(); } public void cancelLoad() { if (currentLoadTask != null) { currentLoadTask.cancel(); currentLoadTask = null; } loadDialogController.reset(); appView.getState().setDialog(ModalDialog.NONE); } public void openCreateEventDialog() { var state = appView.getState(); state.setCreateType(MechanismType.LMSR); createEventDialogController.reset(); state.setDialog(ModalDialog.CREATE_EVENT); } public void closeDialog() { appView.getState().setDialog(ModalDialog.NONE); } public void openResolveDialog() { var state = appView.getState(); state.setResolveSelectedOption(null); resolveDialogController.reset(); state.setDialog(ModalDialog.RESOLVE); } public void createEvent(CreateEventRequest req) { var state = appView.getState(); state.setCreateType(req.mechanism()); var requestWithMM = new CreateEventRequest( req.name(), req.description(), req.mechanism(), req.commissionPercent(), req.commissionTiming(), req.mmUserName() != null ? req.mmUserName() : state.getActingUserName(), req.liquidityB(), req.baseValueD(), req.options(), req.allowMinting()); var res = appView.getCatalogContext().createEvent(requestWithMM); if (res.isSuccess()) { state.refreshData(); state.setDialog(ModalDialog.NONE); state.setSelectedEventId(res.getData().summary().key()); toast("Event created — you are its market maker"); } else { toast("Failed to create event: " + res.getMessage()); } } public void resolveEvent(String option) { var state = appView.getState(); var e = state.getSelectedEvent(); if (e == null) return; var res = appView.getMarketContext().settleEvent(state.getActingUserName(), e.id, option); if (res.isSuccess()) { state.refreshData(); state.setDialog(ModalDialog.NONE); toast("Event resolved as " + option); } else { toast("Failed to resolve event: " + res.getMessage()); } } public void openEvent(EventData e) { var state = appView.getState(); if (e.status == EventStatus.DRAFT) { var res = appView.getCatalogContext().openEvent(e.id); if (!res.isSuccess()) { toast("Failed to open event: " + res.getMessage()); return; } state.refreshData(); } // The trade panel lives on the Users tab and follows the selected event. state.setSelectedEventId(e.id); state.setActiveTab(AppTab.USERS); toast("Trading " + e.name + " as " + state.getActingUserName()); } public void placeOrder(String eventKey, String optionKey, BigDecimal price, long quantity) { placeOrder(eventKey, optionKey, "BUY", price, quantity); } public void placeOrder( String eventKey, String optionKey, String side, BigDecimal price, long quantity) { var state = appView.getState(); var res = appView .getMarketContext() .placeOrder(state.getActingUserName(), eventKey, optionKey, side, price, quantity); if (res.isSuccess()) { state.refreshData(); var receipt = res.getData(); boolean isBuy = "BUY".equalsIgnoreCase(side); if (receipt.trades() == null || receipt.trades().isEmpty()) { toast("Resting " + side + " order placed: " + quantity + " @ " + Format.money(price)); } else { toast( "Order placed  " + (isBuy ? "Paid " : "Received ") + Format.money(new BigDecimal(receipt.totalPaid())) + " for " + quantity + " " + optionKey); } } else { toast("Trade failed: " + res.getMessage()); } } }