From ada8f8c6bb050ebad6fa7bb797c68ad7626359fa Mon Sep 17 00:00:00 2001 From: Benedikt Ziemons Date: Fri, 2 Feb 2024 14:49:39 +0100 Subject: [PATCH] Use futures in prompts, add QInputDialog for vis --- factorygame/data/common.py | 46 ++++--- factorygame/data/fetch.py | 4 +- factorygame/data/sfp.py | 4 +- factorygame/data/vis.py | 249 ++++++++++++++++++++++--------------- factorygame/helper.py | 15 ++- 5 files changed, 195 insertions(+), 123 deletions(-) diff --git a/factorygame/data/common.py b/factorygame/data/common.py index 0a9ce49..3c606e5 100644 --- a/factorygame/data/common.py +++ b/factorygame/data/common.py @@ -1,3 +1,4 @@ +from concurrent.futures import Future from datetime import timedelta, datetime from typing import Callable, Optional @@ -17,32 +18,45 @@ def resource_needs_update(resource: Resource | None, recipe_info_timeout: Option ) -def chose_resource(session: AlchemySession, resource_label: str, prompt: Callable) -> Resource | None: +def chose_resource(session: AlchemySession, resource_label: str, prompt: Callable) -> Future[Resource | None]: matching_resources = session.scalars(Resource.by_label(resource_label)).all() + ret = Future() if len(matching_resources) == 0: - print("Could not find existing resources matching the search string.. starting wiki search") + print("Could not find any resource matching the search string…") + ret.set_result(None) else: options = {(idx + 1): str(matching_resources[idx].label) for idx in range(len(matching_resources))} - options[0] = "" - selected_res = prompt( - options=options, text="Chose a resource to continue or 0 to continue with a wiki search", default=1 - ) - if selected_res is not None and selected_res != 0: - return matching_resources[selected_res - 1] - return None + options[0] = "Continue with wiki search…" + + def resource_selected_cb(res_future: Future[int | None]): + selected_res = res_future.result() + if selected_res is not None and selected_res != 0: + ret.set_result(matching_resources[selected_res - 1]) + else: + ret.set_result(None) + + prompt(options=options, text="Chose a resource", default=1).add_done_callback(resource_selected_cb) + return ret -def chose_recipe(session: AlchemySession, resource: Resource, prompt: Callable) -> Recipe | None: +def chose_recipe(session: AlchemySession, resource: Resource, prompt: Callable) -> Future[Recipe | None]: stmt = select(Recipe).join(Recipe.results).filter(ResourceFlow.resource_id == resource.id) recipes = session.scalars(stmt).all() + ret = Future() if len(recipes) == 0: print("No recipes found for resource") - return None + ret.set_result(None) elif len(recipes) > 1: options = {(idx + 1): recipes[idx].describe() for idx in range(len(recipes))} - user_choice = prompt(options=options, text="Select recipe", default=1) - if user_choice is None: - return None - return recipes[user_choice - 1] + + def user_choice_cb(choice_future: Future[int | None]): + user_choice = choice_future.result() + if user_choice is None: + ret.set_result(None) + else: + ret.set_result(recipes[user_choice - 1]) + + prompt(options=options, text="Select recipe", default=1).add_done_callback(user_choice_cb) else: - return recipes[0] + ret.set_result(recipes[0]) + return ret diff --git a/factorygame/data/fetch.py b/factorygame/data/fetch.py index 6d3de58..8175a14 100755 --- a/factorygame/data/fetch.py +++ b/factorygame/data/fetch.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from factorygame.data.common import resource_needs_update, chose_resource from .models import Base, ResourceFlow, Recipe from .sfp import SatisfactoryPlus -from ..helper import prompt +from ..helper import click_prompt @click.command() @@ -20,7 +20,7 @@ def main(result: bool, debug: bool, refetch: bool, search: str): Base.metadata.create_all(bind=engine) if result and search: with Session(engine) as session: - resource = chose_resource(session=session, resource_label=search, prompt=prompt) + resource = chose_resource(session=session, resource_label=search, prompt=click_prompt).result() exists_in_db = resource is not None with SatisfactoryPlus(debug=debug) as data_provider: diff --git a/factorygame/data/sfp.py b/factorygame/data/sfp.py index 801d544..5ff1663 100644 --- a/factorygame/data/sfp.py +++ b/factorygame/data/sfp.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Session as AlchemySession from .models import Resource, ResourceFlow, Factory, Recipe from .provider import RecipeProvider -from ..helper import prompt +from ..helper import click_prompt class SatisfactoryPlus(RecipeProvider, AbstractContextManager): @@ -66,7 +66,7 @@ class SatisfactoryPlus(RecipeProvider, AbstractContextManager): options[idx + 1] = name if name.casefold() == search.casefold(): default_choice = idx + 1 - user_choice = prompt(options=options, text="Chose a recipe to continue…", default=default_choice) + user_choice = click_prompt(options=options, text="Chose a recipe to continue…", default=default_choice) if user_choice is None: return None link_html_elem = choices[user_choice - 1] diff --git a/factorygame/data/vis.py b/factorygame/data/vis.py index 20371cc..128b632 100755 --- a/factorygame/data/vis.py +++ b/factorygame/data/vis.py @@ -1,22 +1,23 @@ #!/usr/bin/env python3 import re +from concurrent.futures import Future from datetime import timedelta import click from NodeGraphQt import BaseNode, NodeBaseWidget from NodeGraphQt import NodeGraph, Port -from NodeGraphQt.constants import PortTypeEnum, NodePropWidgetEnum +from NodeGraphQt.constants import PortTypeEnum, NodePropWidgetEnum, ViewerEnum from NodeGraphQt.widgets.node_widgets import _NodeGroupBox, NodeLineEdit, NodeCheckBox -from PySide2.QtCore import Qt -from PySide2.QtWidgets import QSlider, QLineEdit, QCheckBox, QGraphicsItem +from PySide2 import QtGui +from PySide2.QtCore import Qt, QObject +from PySide2.QtWidgets import QSlider, QLineEdit, QCheckBox, QGraphicsItem, QInputDialog from Qt import QtWidgets from sqlalchemy import create_engine from sqlalchemy.orm import Session from factorygame.data.common import chose_resource, resource_needs_update, chose_recipe from .models import Recipe, Resource, ResourceFlow -from ..helper import prompt WORLD_OUTPUT_PORT_NAME = "World Output" WORLD_OUTPUT_COLOR = (0, 139, 41) @@ -28,9 +29,6 @@ OUTPUT_COLOR = (204, 44, 36) OTHER_COLOR = (0, 83, 135) -graph: NodeGraph - - def in_amount_name(label: str) -> str: return f"In {label} amount" @@ -134,11 +132,11 @@ class GlobalInput(BaseNode): self.delete_output(CREATE_MACHINE_PORT_NAME) self.add_special_ports() - def update_x_pos(self): + def update_x_pos(self, graph: NodeGraph): min_x_pos = min(map(lambda node: node.x_pos(), filter(lambda node: node != self, graph.all_nodes()))) self.set_x_pos(min_x_pos - self.view.width - 150) - def create_global_output(self, resource_label: str, initial_resource_amount: str) -> Port: + def create_global_output(self, graph: NodeGraph, resource_label: str, initial_resource_amount: str) -> Port: new_output_port = self.add_output(name=resource_label, multi_output=False, color=OUTPUT_COLOR) widget = add_resource_text( node=self, @@ -149,7 +147,7 @@ class GlobalInput(BaseNode): self.output_resources[resource_label] = parse_resource_amount(initial_resource_amount) widget.value_changed.connect(lambda: self.update_resource_output(resource_label)) self.reorder_outputs() - self.update_x_pos() + self.update_x_pos(graph=graph) return new_output_port def update_resource_output(self, resource_label: str): @@ -323,106 +321,161 @@ class Machine(BaseNode): return self.output_resources[resource_label] * (int(self.get_property(Machine.ACTUAL_PERFORMANCE_PROP)) / 100.0) -def on_port_connected(input_port: Port, output_port: Port): - global debug - if debug: - print(f"Port {output_port} connected to {input_port}") - output_node = output_port.node() - if isinstance(output_node, GlobalInput): - resource_label = input_port.name() - if output_port.name() == CREATE_MACHINE_PORT_NAME: - output_port.clear_connections(push_undo=False, emit_signal=False) - with Session(engine) as session: - resource = session.scalars(Resource.by_label(resource_label)).one_or_none() - if resource_needs_update(resource, recipe_info_timeout=timedelta(days=365)): - print("Please fetch resource", resource_label, "first.") +class GraphController(QObject): + def __init__(self, debug: bool, parent=None): + super().__init__(parent=parent) + self.debug = debug + + bg_color = QtGui.QColor(*ViewerEnum.BACKGROUND_COLOR.value).darker(120).getRgb() + text_color = tuple(map(str, map(lambda i, j: i - j, (255, 255, 255), bg_color))) + self.fgbg_color_stylesheet = ( + "* {" + " background-color: rgba(" + ",".join(map(str, bg_color)) + ") ;" + " color: rgb(" + ",".join(text_color) + ");" + "}" + ) + + self.engine = create_engine("sqlite:///file.db", echo=debug) + self.graph = NodeGraph(parent=self) + self.graph.widget.resize(1280, 720) + self.graph.register_node(Machine) + self.graph.register_node(GlobalInput) + + self.global_input: GlobalInput = self.graph.create_node("factorygame.GlobalInput", push_undo=False) + + self.graph.port_connected.connect(self.on_port_connected) + + def add_machine_from_search(self, search: str): + recipe_selected_future = Future() + + def resource_selected_cb(resource_future: Future[Resource | None]): + resource = resource_future.result() + if resource is None: + # FIXME: use concurrent resource searching/fetching + # ret = data_provider.search_for_resource(session=session, search=search) + ret = None + if ret is None: + print("Resource not found") + recipe_selected_future.set_result(None) return - # FIXME: use some Qt UI prompt method - recipe = chose_recipe(session=session, resource=resource, prompt=prompt) - if recipe is None: + resource, exists_in_db = ret + if not exists_in_db: + print("Resource not yet fetched, run fetch first") + recipe_selected_future.set_result(None) return + if resource_needs_update(resource, recipe_info_timeout=timedelta(days=365)): + print("Please fetch resource", resource.label, "first.") + recipe_selected_future.set_result(None) + return - recipe_machine: Machine = graph.create_node("factorygame.Machine", push_undo=True) - recipe_machine.assign_recipe(recipe) - recipe_machine.update() - recipe_machine.set_x_pos(input_port.node().x_pos() - recipe_machine.view.width - 100) - recipe_machine.get_output(resource_label).connect_to(input_port) - output_node.update_x_pos() - elif output_port.name() == WORLD_OUTPUT_PORT_NAME: - output_port.clear_connections(push_undo=False, emit_signal=False) - for idx, port in enumerate(output_node.output_ports()): - assert port.name() != resource_label, f"Duplicate output port for {resource_label}" - # if port.name() == resource_label: - # port.connect_to(input_port, push_undo=True, emit_signal=False) - # return - if isinstance(input_port.node(), Machine): - machine_node: Machine = input_port.node() - initial_resource_amount = str(machine_node.get_property(in_amount_name(resource_label))) - new_output_port = output_node.create_global_output( - resource_label=resource_label, - initial_resource_amount=initial_resource_amount, + with Session(self.engine) as session: + chose_recipe(session=session, resource=resource, prompt=self.dialog_prompt).add_done_callback( + lambda fut: recipe_selected_future.set_result(fut.result()) ) - new_output_port.connect_to(input_port, push_undo=True, emit_signal=False) - elif isinstance(output_node, Machine): - input_node = input_port.node() - resource_label = output_port.name() - if isinstance(input_node, Machine): - resource_amount = output_node.get_resource_output(resource_label) - input_node.update_input( - resource_label, - resource_amount, - ) + + def select_recipe_async(): + with Session(self.engine) as session: + resource_future = chose_resource(session=session, resource_label=search, prompt=self.dialog_prompt) + resource_future.add_done_callback(resource_selected_cb) + + def recipe_selections_cb(recipe_future: Future[Recipe | None]): + recipe = recipe_future.result() + if recipe is not None: + recipe_machine: Machine = self.graph.create_node("factorygame.Machine", push_undo=False) + recipe_machine.assign_recipe(recipe) + self.graph.auto_layout_nodes([self.global_input, recipe_machine]) + self.global_input.set_y_pos(recipe_machine.y_pos()) + self.global_input.update_x_pos(graph=self.graph) + self.graph.center_on([self.global_input, recipe_machine]) + + recipe_selected_future.add_done_callback(recipe_selections_cb) + select_recipe_async() + + def show(self): + self.graph.widget.show() + + def on_port_connected(self, input_port: Port, output_port: Port): + if self.debug: + print(f"Port {output_port} connected to {input_port}") + output_node = output_port.node() + if output_node == self.global_input: + resource_label = input_port.name() + if output_port.name() == CREATE_MACHINE_PORT_NAME: + output_port.clear_connections(push_undo=False, emit_signal=False) + + def recipe_selected_cb(recipe_future: Future[Recipe | None]): + recipe = recipe_future.result() + if recipe is not None: + recipe_machine: Machine = self.graph.create_node("factorygame.Machine", push_undo=True) + recipe_machine.assign_recipe(recipe) + recipe_machine.update() + recipe_machine.set_x_pos(input_port.node().x_pos() - recipe_machine.view.width - 100) + recipe_machine.get_output(resource_label).connect_to(input_port) + self.global_input.update_x_pos(graph=self.graph) + + with Session(self.engine) as session: + resource = session.scalars(Resource.by_label(resource_label)).one_or_none() + if resource_needs_update(resource, recipe_info_timeout=timedelta(days=365)): + print("Please fetch resource", resource_label, "first.") + return + + chose_recipe(session=session, resource=resource, prompt=self.dialog_prompt).add_done_callback( + recipe_selected_cb + ) + elif output_port.name() == WORLD_OUTPUT_PORT_NAME: + output_port.clear_connections(push_undo=False, emit_signal=False) + for idx, port in enumerate(self.global_input.output_ports()): + assert port.name() != resource_label, f"Duplicate output port for {resource_label}" + # if port.name() == resource_label: + # port.connect_to(input_port, push_undo=True, emit_signal=False) + # return + if isinstance(input_port.node(), Machine): + machine_node: Machine = input_port.node() + initial_resource_amount = str(machine_node.get_property(in_amount_name(resource_label))) + new_output_port = self.global_input.create_global_output( + graph=self.graph, + resource_label=resource_label, + initial_resource_amount=initial_resource_amount, + ) + new_output_port.connect_to(input_port, push_undo=True, emit_signal=False) + elif isinstance(output_node, Machine): + input_node = input_port.node() + resource_label = output_port.name() + if isinstance(input_node, Machine): + resource_amount = output_node.get_resource_output(resource_label) + input_node.update_input( + resource_label, + resource_amount, + ) + + def dialog_prompt(self, options: dict[int, str], text: str, default: int) -> Future[int | None]: + if self.debug: + print("Displaying QInputDialog with options:", ", ".join(options.values())) + + dialog = QInputDialog(parent=self.graph.widget) + dialog.setStyleSheet(self.fgbg_color_stylesheet) + dialog.setModal(True) + dialog.setLabelText(text) + if default in options: + dialog.setTextValue(options[default]) + reversed_options: dict[str, int] = {value: key for key, value in options.items()} + dialog.setComboBoxItems(reversed_options.keys()) + ret = Future() + dialog.rejected.connect(lambda: ret.set_result(None)) + dialog.accepted.connect(lambda: ret.set_result(reversed_options[dialog.textValue()])) + dialog.show() + return ret @click.command @click.option("--debug", is_flag=True) @click.argument("search") def main(debug: bool, search: str): - global engine - globals()["debug"] = debug - engine = create_engine("sqlite:///file.db", echo=debug) - with Session(engine) as session: - # FIXME: use some Qt UI prompt method - resource = chose_resource(session=session, resource_label=search, prompt=prompt) - - if resource is None: - # FIXME: use concurrent resource searching/fetching - # ret = data_provider.search_for_resource(session=session, search=search) - ret = None - if ret is None: - print("Resource not found") - return - - resource, exists_in_db = ret - if not exists_in_db: - print("Resource not yet fetched, run fetch first") - return - if resource_needs_update(resource, recipe_info_timeout=timedelta(days=365)): - print("Please fetch resource", resource.label, "first.") - return - - # FIXME: use some Qt UI prompt method - recipe = chose_recipe(session=session, resource=resource, prompt=prompt) - if recipe is None: - return - app = QtWidgets.QApplication([]) - global graph - graph = NodeGraph() - graph.widget.resize(1280, 720) - graph.register_node(Machine) - graph.register_node(GlobalInput) - graph_widget = graph.widget - graph_widget.show() - global_input = graph.create_node("factorygame.GlobalInput", push_undo=False) - recipe_machine = graph.create_node("factorygame.Machine", push_undo=False) - recipe_machine.assign_recipe(recipe) - graph.auto_layout_nodes([global_input, recipe_machine]) - global_input.set_y_pos(recipe_machine.y_pos()) - global_input.update_x_pos() - graph.center_on([global_input, recipe_machine]) - graph.port_connected.connect(on_port_connected) + graph_controller = GraphController(debug=debug) + graph_controller.show() + graph_controller.add_machine_from_search(search=search) app.exec_() diff --git a/factorygame/helper.py b/factorygame/helper.py index 5a59252..9775f3e 100644 --- a/factorygame/helper.py +++ b/factorygame/helper.py @@ -1,13 +1,18 @@ +from concurrent.futures import Future + import click -def prompt(options: dict[int, str], **kwargs) -> int | None: +def click_prompt(options: dict[int, str], text: str, default: int) -> Future[int | None]: for idx, label in options.items(): if label: click.echo(f"{idx}: {label}") - ret = click.prompt(**kwargs) - ret = int(ret) - if ret not in options: + user_choice = click.prompt(text=text, default=default) + user_choice = int(user_choice) + ret = Future() + if user_choice not in options: click.echo("Invalid choice.") - return None + ret.set_result(None) + else: + ret.set_result(user_choice) return ret