diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 76a4c1c..f7fce85 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -52,7 +52,7 @@ jobs: run: | mkdir build cd build - cmake -S .. -G Ninja + cmake -S .. -G Ninja -D AP_BASE_URL=PathwaysGenerator - name: Build web app run: | cmake --build build --target web_app diff --git a/CMakeLists.txt b/CMakeLists.txt index 0ebfe49..1017a8c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,6 +19,10 @@ enable_testing() list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/environment/cmake) +set(AP_BASE_URL "" CACHE STRING + "Base url for web app. Use empty string (the default) for testing locally." +) + find_package(Flet REQUIRED) find_package(Python3 REQUIRED COMPONENTS Interpreter) find_package(Quarto) diff --git a/environment/configuration/requirements.txt b/environment/configuration/requirements.txt index 8286872..7cda1bf 100644 --- a/environment/configuration/requirements.txt +++ b/environment/configuration/requirements.txt @@ -1,3 +1,6 @@ docopt matplotlib networkx[default] +pyside6>=6.6 +flet==0.25.* +pyodide-py diff --git a/pyproject.toml b/pyproject.toml index baf4334..365ca98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ profile = "black" [tool.pylint] max-line-length=240 -disable = "C0103, C0114, C0115, C0116, C0302, E0401, W0212, W0511, R0801, R0902, R0903, R0913, R0914, R0904, R0917" +disable = "C0103, C0114, C0115, C0116, C0302, E0401, W0212, W0511, R0801, R0902, R0903, R0913, R0914, R0904, R0917, W0718" extension-pkg-allow-list = [ "matplotlib", ] diff --git a/source/package/adaptation_pathways/app/model/__init__.py b/source/package/adaptation_pathways/app/model/__init__.py index 533863c..e30d535 100644 --- a/source/package/adaptation_pathways/app/model/__init__.py +++ b/source/package/adaptation_pathways/app/model/__init__.py @@ -6,4 +6,4 @@ from .metric import Metric from .pathway import Pathway from .pathways_project import PathwaysProject -from .scenario import Scenario, TimeSeriesPoint +from .scenario import Scenario diff --git a/source/package/adaptation_pathways/app/model/metric.py b/source/package/adaptation_pathways/app/model/metric.py index 692c0f0..e2409b4 100644 --- a/source/package/adaptation_pathways/app/model/metric.py +++ b/source/package/adaptation_pathways/app/model/metric.py @@ -39,7 +39,6 @@ def format(self, value: float): class Metric: id: str name: str - current_value: float unit_or_default: MetricUnit | str @property @@ -57,10 +56,20 @@ def __hash__(self) -> int: return self.id.__hash__() +class MetricValueState(Enum): + BASE = 0 + ESTIMATE = (1,) + OVERRIDE = 2 + + @dataclasses.dataclass class MetricValue: value: float - is_estimate: bool = False + state: MetricValueState = MetricValueState.BASE + + @property + def is_estimate(self): + return self.state == MetricValueState.ESTIMATE class MetricOperation(Enum): @@ -138,14 +147,15 @@ class Volume: si = [ MetricUnit(name="Milliliter", symbol="ml"), MetricUnit(name="Liter", symbol="l"), - MetricUnit(name="Cubic Centimeter", symbol="cm^3"), - MetricUnit(name="Cubic Meter", symbol="m^3"), + MetricUnit(name="Cubic Centimeter", symbol="cm³"), + MetricUnit(name="Cubic Meter", symbol="m³"), ] imperial = [ MetricUnit(name="Fluid Ounce", symbol="fl oz"), MetricUnit(name="Pint", symbol="pt"), MetricUnit(name="Quart", symbol="qt"), MetricUnit(name="Gallon", symbol="gal"), + MetricUnit(name="Acre Feet", symbol="ac-ft"), ] volume = Volume() @@ -224,7 +234,7 @@ class MassWeight: ] relative = [ - MetricUnit(name="Percent", symbol="%", value_format=".2%"), + MetricUnit(name="Percent", symbol="%", value_format=".2"), MetricUnit( name="Impact", symbol="", short_name="Impact", value_format=FORMAT_SLIDER ), diff --git a/source/package/adaptation_pathways/app/model/pathways_project.py b/source/package/adaptation_pathways/app/model/pathways_project.py index ad0510c..6944506 100644 --- a/source/package/adaptation_pathways/app/model/pathways_project.py +++ b/source/package/adaptation_pathways/app/model/pathways_project.py @@ -2,12 +2,11 @@ """ The single class that stores all data needed to work on a project """ -from typing import Callable, Iterable - -from adaptation_pathways.app.model.sorting import SortingInfo, SortTarget +from json import JSONEncoder +from typing import Iterable from .action import Action -from .metric import Metric, MetricEffect, MetricOperation, MetricValue +from .metric import Metric, MetricEffect, MetricOperation, MetricValue, MetricValueState from .pathway import Pathway from .scenario import Scenario @@ -20,13 +19,22 @@ def __init__( organization: str, start_year: int, end_year: int, - conditions: list[Metric], - criteria: list[Metric], - scenarios: list[Scenario], - actions: list[Action], - pathways: list[Pathway], - root_action: Action, - root_pathway_id: str, + conditions_by_id: dict[str, Metric] | None = None, + condition_ids: list[str] | None = None, + criteria_by_id: dict[str, Metric] | None = None, + criteria_ids: list[str] | None = None, + actions_by_id: dict[str, Action] | None = None, + action_ids: list[str] | None = None, + scenarios_by_id: dict[str, Scenario] | None = None, + scenario_ids: list[str] | None = None, + pathways_by_id: dict[str, Pathway] | None = None, + pathway_ids: list[str] | None = None, + root_action_id: str = "", + root_pathway_id: str = "", + values_scenario_id: str | None = None, + graph_metric_id: str | None = None, + graph_scenario_id: str | None = None, + graph_is_time=False, ): self.id = project_id self.name = name @@ -35,181 +43,174 @@ def __init__( self.end_year = end_year self._current_id = 0 - self.metrics_by_id: dict[str, Metric] = {} - for metric in conditions: - self.metrics_by_id[metric.id] = metric - - for metric in criteria: - self.metrics_by_id[metric.id] = metric - - self.scenarios_by_id: dict[str, Scenario] = {} - for scenario in scenarios: - self.scenarios_by_id[scenario.id] = scenario - - self.actions_by_id: dict[str, Action] = {} - self.actions_by_id[root_action.id] = root_action - for action in actions: - self.actions_by_id[action.id] = action - - self.pathways_by_id: dict[str, Pathway] = {} - for pathway in pathways: - self.pathways_by_id[pathway.id] = pathway - - self.condition_sorting = SortingInfo([metric.id for metric in conditions]) - self.criteria_sorting = SortingInfo([metric.id for metric in criteria]) - self.scenario_sorting = SortingInfo([scenario.id for scenario in scenarios]) - self.action_sorting = SortingInfo([action.id for action in actions]) - self.pathway_sorting = SortingInfo([pathway.id for pathway in pathways]) - - self.root_pathway_id = root_pathway_id - self.selected_condition_ids: set[str] = set() - self.selected_criteria_ids: set[str] = set() - self.selected_action_ids: set[str] = set() - self.selected_pathway_ids: set[str] = set() - self.selected_scenario_id: str = "" if len(scenarios) == 0 else scenarios[0].id - self.graph_metric_id: str = conditions[0].id - - self.on_conditions_changed: list[Callable[[], None]] = [] - self.on_criteria_changed: list[Callable[[], None]] = [] - self.on_scenarios_changed: list[Callable[[], None]] = [] - self.on_actions_changed: list[Callable[[], None]] = [] - self.on_action_color_changed: list[Callable[[], None]] = [] - self.on_pathways_changed: list[Callable[[], None]] = [] + self.condition_ids = condition_ids or [] + self.conditions_by_id = conditions_by_id or {} + self.criteria_ids = criteria_ids or [] + self.criteria_by_id = criteria_by_id or {} - def __hash__(self): - return self.id.__hash__() + self.scenario_ids = scenario_ids or [] + self.scenarios_by_id = scenarios_by_id or {} - def notify_conditions_changed(self): - for listener in self.on_conditions_changed: - listener() + self.action_ids = action_ids or [] + self.actions_by_id = actions_by_id or {} - def notify_criteria_changed(self): - for listener in self.on_criteria_changed: - listener() + self.pathway_ids = pathway_ids or [] + self.pathways_by_id = pathways_by_id or {} - def notify_scenarios_changed(self): - for listener in self.on_scenarios_changed: - listener() + self.root_pathway_id = root_pathway_id or "" + self.root_action_id = root_action_id or "" - def notify_actions_changed(self): - for listener in self.on_actions_changed: - listener() + self.values_scenario_id = values_scenario_id or "none" + self.graph_metric_id = graph_metric_id or "none" + self.graph_scenario_id = graph_scenario_id or "none" + self.graph_is_time = graph_is_time - def notify_action_color_changed(self): - for listener in self.on_action_color_changed: - listener() - - def notify_pathways_changed(self): - for listener in self.on_pathways_changed: - listener() + def __hash__(self): + return self.id.__hash__() @property - def sorted_actions(self): - return ( - self.get_action(action_id) for action_id in self.action_sorting.sorted_ids - ) + def all_conditions(self) -> Iterable[Metric]: + return (self.conditions_by_id[metric_id] for metric_id in self.condition_ids) @property - def sorted_conditions(self): - return ( - self.get_metric(metric_id) - for metric_id in self.condition_sorting.sorted_ids - ) + def all_criteria(self) -> Iterable[Metric]: + return (self.criteria_by_id[metric_id] for metric_id in self.criteria_ids) @property - def sorted_criteria(self): - return ( - self.get_metric(metric_id) for metric_id in self.criteria_sorting.sorted_ids - ) + def all_scenarios(self) -> Iterable[Scenario]: + return (self.scenarios_by_id[scenario_id] for scenario_id in self.scenario_ids) @property - def sorted_scenarios(self): - return ( - self.get_scenario(scenario_id) - for scenario_id in self.scenario_sorting.sorted_ids - ) + def all_actions(self) -> Iterable[Action]: + return (self.actions_by_id[action_id] for action_id in self.action_ids) @property - def sorted_pathways(self): - return ( - self.get_pathway(pathway_id) - for pathway_id in self.pathway_sorting.sorted_ids - ) + def all_pathways(self) -> Iterable[Pathway]: + return (self.pathways_by_id[pathway_id] for pathway_id in self.pathway_ids) @property def root_pathway(self): return self.get_pathway(self.root_pathway_id) + @property + def values_scenario(self): + return self.get_scenario(self.values_scenario_id) + @property def graph_metric(self): return self.get_metric(self.graph_metric_id) + @property + def graph_scenario(self): + return self.get_scenario(self.graph_scenario_id) + def _create_id(self) -> str: self._current_id += 1 return str(self._current_id) def get_metric(self, metric_id: str) -> Metric | None: - return self.metrics_by_id.get(metric_id, None) + metric = self.conditions_by_id.get(metric_id, None) + if metric is None: + metric = self.criteria_by_id.get(metric_id, None) + return metric def all_metrics(self): - for metric_id in self.condition_sorting.sorted_ids: - yield self.get_metric(metric_id) - for metric_id in self.criteria_sorting.sorted_ids: - yield self.get_metric(metric_id) + yield from self.all_conditions + yield from self.all_criteria - def _create_metric(self, name: str) -> Metric: + def _create_metric( + self, name: str, metrics_by_id: dict[str, Metric], metric_ids: list[str] + ) -> Metric: metric_id = self._create_id() - metric = Metric(metric_id, name, 0, "") - self.metrics_by_id[metric.id] = metric - for action in self.sorted_actions: + metric = Metric(metric_id, name, "") + metrics_by_id[metric_id] = metric + metric_ids.append(metric_id) + + for action in self.all_actions: action.metric_data[metric_id] = MetricEffect(0, MetricOperation.ADD) + + self.update_pathway_values(metric.id) return metric def create_condition(self) -> Metric: - metric = self._create_metric("New Condition") - self.condition_sorting.sorted_ids.append(metric.id) + metric = self._create_metric( + "New Condition", self.conditions_by_id, self.condition_ids + ) + if self.graph_metric_id == "none": + self.graph_metric_id = metric.id + return metric def create_criteria(self) -> Metric: - metric = self._create_metric("New Criteria") - self.criteria_sorting.sorted_ids.append(metric.id) + metric = self._create_metric( + "New Criteria", self.criteria_by_id, self.criteria_ids + ) return metric def delete_condition(self, metric_id: str) -> Metric | None: - metric = self.metrics_by_id.pop(metric_id) - self.condition_sorting.sorted_ids.remove(metric_id) - self.selected_condition_ids.remove(metric_id) + metric = self.conditions_by_id.pop(metric_id) + self.condition_ids.remove(metric_id) return metric def delete_criteria(self, metric_id: str) -> Metric | None: - metric = self.metrics_by_id.pop(metric_id) - self.criteria_sorting.sorted_ids.remove(metric_id) - self.selected_criteria_ids.remove(metric_id) + metric = self.criteria_by_id.pop(metric_id) + self.criteria_ids.remove(metric_id) return metric def get_scenario(self, scenario_id: str) -> Scenario | None: return self.scenarios_by_id.get(scenario_id, None) - def create_scenario(self) -> Scenario: + def create_scenario(self, name: str) -> Scenario: scenario_id = self._create_id() - scenario = Scenario(scenario_id, "New Scenario", {}) + scenario = Scenario(scenario_id, name) self.scenarios_by_id[scenario.id] = scenario - self.scenario_sorting.sorted_ids.append(scenario.id) + self.scenario_ids.append(scenario.id) + + if self.graph_scenario_id == "none": + self.graph_scenario_id = scenario_id + return scenario + def copy_scenario(self, scenario_id: str, suffix=" (Copy)") -> Scenario | None: + to_copy = self.get_scenario(scenario_id) + if to_copy is None: + return None + + new_scenario = self.create_scenario(f"{to_copy.name}{suffix}") + for year_data in to_copy.yearly_data: + new_data = new_scenario.get_or_add_year(year_data.year) + for metric_id, metric_data in year_data.metric_data.items(): + new_data.metric_data[metric_id] = MetricValue( + metric_data.value, metric_data.state + ) + + return new_scenario + def delete_scenario(self, scenario_id: str) -> Scenario | None: scenario = self.scenarios_by_id.pop(scenario_id) - self.scenario_sorting.sorted_ids.remove(scenario_id) + self.scenario_ids.remove(scenario_id) + if self.graph_scenario_id == scenario_id: + self.graph_scenario_id = ( + self.scenario_ids[0] if len(self.scenario_ids) > 0 else "none" + ) return scenario + def delete_scenarios(self, scenario_ids: Iterable[str]): + for scenario_id in scenario_ids: + self.delete_scenario(scenario_id) + + def update_scenario_values(self, metric_id: str): + for scenario in self.all_scenarios: + scenario.recalculate_values(metric_id) + def get_action(self, action_id: str) -> Action: return self.actions_by_id[action_id] - def create_action(self, color, icon) -> Action: + def create_action(self, color: str, icon: str, name: str | None = None) -> Action: action_id = self._create_id() action = Action( action_id, - "New Action", + name or f"New Action ({action_id})", color, icon, { @@ -219,61 +220,28 @@ def create_action(self, color, icon) -> Action: ) self.actions_by_id[action.id] = action - self.action_sorting.sorted_ids.append(action.id) + self.action_ids.append(action.id) return action def delete_action(self, action_id: str) -> Action | None: action = self.actions_by_id.pop(action_id) - self.action_sorting.sorted_ids.remove(action_id) + self.action_ids.remove(action_id) return action - def delete_selected_actions(self): + def delete_actions(self, action_ids: Iterable[str]): pathway_ids_to_delete: list[str] = [] - for action_id in self.selected_action_ids: + for action_id in action_ids: self.delete_action(action_id) - for pathway in self.sorted_pathways: + for pathway in self.all_pathways: if pathway.action_id == action_id: pathway_ids_to_delete.append(pathway.id) - self.selected_action_ids.clear() self.delete_pathways(pathway_ids_to_delete) - def sort_actions(self): - if self.action_sorting.target is SortTarget.METRIC: - sorting_metric = self.get_metric(self.action_sorting.sort_key) - - if sorting_metric is not None: - - def sort_by_metric(action_id: str): - action = self.get_action(action_id) - if action is None: - return 0 - data = action.metric_data.get(sorting_metric.id, None) - return data.value if data is not None else 0 - - self.action_sorting.sorted_ids.sort( - key=sort_by_metric, - reverse=not self.action_sorting.ascending, - ) - - elif self.action_sorting.target is SortTarget.ATTRIBUTE: - - def sort_by_attr(action_id: str): - if self.action_sorting.sort_key is None: - return "" - action = self.get_action(action_id) - return getattr(action, self.action_sorting.sort_key, "") - - self.action_sorting.sorted_ids.sort( - key=sort_by_attr, reverse=not self.action_sorting.ascending - ) - - else: - self.action_sorting.ascending = True - self.action_sorting.sort_by_id() - def get_pathway(self, pathway_id: str) -> Pathway | None: + if pathway_id is None: + return None return self.pathways_by_id.get(pathway_id, None) def create_pathway( @@ -281,7 +249,8 @@ def create_pathway( ) -> Pathway: pathway = Pathway(action_id, parent_pathway_id) self.pathways_by_id[pathway.id] = pathway - self.pathway_sorting.sorted_ids.append(pathway.id) + self.pathway_ids.append(pathway.id) + for metric in self.all_metrics(): self.update_pathway_values(metric.id) @@ -293,42 +262,55 @@ def update_pathway_values(self, metric_id: str): return updated_pathways: set[str] = set() - for pathway in self.sorted_pathways: + for pathway in self.all_pathways: self._update_pathway_value(pathway, metric, updated_pathways) def _update_pathway_value( self, pathway: Pathway, metric: Metric, updated_pathway_ids: set[str] ): pathway_action = self.get_action(pathway.action_id) - current_value = pathway.metric_data.get(metric.id, MetricValue(0, True)) - pathway.metric_data[metric.id] = current_value - - if current_value is not None and not current_value.is_estimate: - updated_pathway_ids.add(pathway.id) - return + parent = ( + None if pathway.parent_id is None else self.get_pathway(pathway.parent_id) + ) + current_value = pathway.metric_data.get(metric.id, None) + + # Initialize the value if there was none + if current_value is None: + current_value = MetricValue( + 0, + ( + MetricValueState.ESTIMATE + if parent is not None + else MetricValueState.BASE + ), + ) + pathway.metric_data[metric.id] = current_value - if pathway.parent_id is None: - pathway.metric_data[metric.id] = MetricValue(metric.current_value) + # If we have a non-estimate value, we don't need to update anything + if current_value.state != MetricValueState.ESTIMATE: updated_pathway_ids.add(pathway.id) return - parent = self.get_pathway(pathway.parent_id) - if parent is None: - pathway.metric_data[metric.id] = MetricValue(metric.current_value) - updated_pathway_ids.add(pathway.id) - return + base_value: float = 0 + if parent is not None: + parent_value = parent.metric_data.get(metric.id, None) - parent_value = parent.metric_data[metric.id] + if ( + parent.id not in updated_pathway_ids + and parent_value is not None + and parent_value.is_estimate + ): + self._update_pathway_value(parent, metric, updated_pathway_ids) - if parent.id not in updated_pathway_ids and parent_value.is_estimate: - self._update_pathway_value(parent, metric, updated_pathway_ids) + if parent_value is not None: + base_value = parent_value.value - current_value.value = pathway_action.apply_effect(metric.id, parent_value.value) + current_value.value = pathway_action.apply_effect(metric.id, base_value) updated_pathway_ids.add(pathway.id) def delete_pathway(self, pathway_id: str) -> Pathway | None: pathway = self.pathways_by_id.pop(pathway_id, None) - self.pathway_sorting.sorted_ids.remove(pathway_id) + self.pathway_ids.append(pathway_id) return pathway def delete_pathways(self, pathway_ids: Iterable[str]): @@ -336,7 +318,7 @@ def delete_pathways(self, pathway_ids: Iterable[str]): ids_to_delete.update(pathway_ids) # Delete any orphaned children - for pathway in self.sorted_pathways: + for pathway in self.all_pathways: if pathway.id in ids_to_delete: continue @@ -347,50 +329,9 @@ def delete_pathways(self, pathway_ids: Iterable[str]): for pathway_id in ids_to_delete: self.delete_pathway(pathway_id) - def delete_selected_pathways(self): - self.delete_pathways(self.selected_pathway_ids) - self.selected_pathway_ids.clear() - - def sort_pathways(self): - if self.pathway_sorting.target is SortTarget.METRIC: - sorting_metric = self.get_metric(self.pathway_sorting.sort_key) - - if sorting_metric is not None: - - def sort_by_metric(pathway_id: str): - pathway = self.get_pathway(pathway_id) - if pathway is None: - return 0 - - data = pathway.metric_data.get(sorting_metric.id, None) - return data.value if data is not None else 0 - - self.pathway_sorting.sorted_ids.sort( - key=sort_by_metric, - reverse=not self.pathway_sorting.ascending, - ) - - elif self.pathway_sorting.target is SortTarget.ATTRIBUTE: - - def sort_by_attr(pathway_id: str): - if self.pathway_sorting.sort_key is None: - return "" - pathway = self.get_pathway(pathway_id) - return getattr(pathway, self.pathway_sorting.sort_key, "") - - self.pathway_sorting.sorted_ids.sort( - key=sort_by_attr, reverse=not self.pathway_sorting.ascending - ) - - else: - self.pathway_sorting.ascending = True - self.pathway_sorting.sort_by_id() - def get_children(self, pathway_id: str): return ( - pathway - for pathway in self.sorted_pathways - if pathway.parent_id == pathway_id + pathway for pathway in self.all_pathways if pathway.parent_id == pathway_id ) def get_ancestors(self, pathway: Pathway): @@ -412,3 +353,8 @@ def get_ancestors_and_self(self, pathway: Pathway): current_pathway = self.get_pathway(current_pathway.parent_id) else: current_pathway = None + + +class PathwaysProjectEncoder(JSONEncoder): + def default(self, o): + return o.__dict__ diff --git a/source/package/adaptation_pathways/app/model/scenario.py b/source/package/adaptation_pathways/app/model/scenario.py index 5f3f502..71dc5e7 100644 --- a/source/package/adaptation_pathways/app/model/scenario.py +++ b/source/package/adaptation_pathways/app/model/scenario.py @@ -1,26 +1,213 @@ -import dataclasses +# pylint: disable=too-many-return-statements,too-many-branches +from .metric import MetricValue, MetricValueState -from .metric import MetricValue +class YearDataPoint: + def __init__(self, year: int): + self.year = year + self.metric_data: dict[str, MetricValue] = {} -@dataclasses.dataclass -class TimeSeriesPoint: - time: float - data: MetricValue | None + def get_or_add_data(self, metric_id: str) -> MetricValue: + data = self.metric_data.get(metric_id, None) + if data is None: + data = MetricValue(0, MetricValueState.ESTIMATE) + self.metric_data[metric_id] = data + return data -@dataclasses.dataclass class Scenario: - id: str - name: str - metric_data_over_time: dict[int, dict[str, MetricValue]] + def __init__(self, scenario_id: str, name: str): + self.id = scenario_id + self.name = name + self.yearly_data: list[YearDataPoint] = [] - def get_data(self, year: int, metric_id: str) -> MetricValue | None: - if year not in self.metric_data_over_time: - return None + def get_or_add_year(self, year: int) -> YearDataPoint: + data = self.get_data(year) + if data is None: + data = YearDataPoint(year) + self.yearly_data.append(data) + self.sort_yearly_data() - year_data = self.metric_data_over_time[year] - if metric_id not in year_data: - return None + return data - return year_data[metric_id] + def get_data(self, year: int) -> YearDataPoint | None: + for data_point in self.yearly_data: + if data_point.year == year: + return data_point + return None + + def set_data(self, year: int, metric_id: str, value: MetricValue): + data = self.get_or_add_year(year) + data.metric_data[metric_id] = value + + def sort_yearly_data(self): + self.yearly_data.sort(key=lambda point: point.year) + + def recalculate_values(self, metric_id: str): + for index, data in enumerate(self.yearly_data): + metric_value = data.get_or_add_data(metric_id) + + # We only recalculate estimated values + if not metric_value.is_estimate: + continue + + previous_point = self._get_previous_value(index, metric_id) + next_point = self._get_next_value(index, metric_id) + + # If we don't have any data to interpolate, we can't recalculate + if previous_point is None and next_point is None: + continue + + # If we have both a previous and next point, we can interpolate + if previous_point is not None and next_point is not None: + metric_value.value = self._estimate_value( + data.year, + previous_point[0], + previous_point[1].value, + next_point[0], + next_point[1].value, + ) + continue + + # If we don't have a previous point, try extrapolating with the next two points + if next_point is not None: + # Check if we have a second data point + second_next_point = self._get_next_value(next_point[2], metric_id) + + # If we don't have a second next data point, we have to use the next one as is + if second_next_point is None: + metric_value.value = next_point[1].value + continue + + # Otherwise we can extrapolate + metric_value.value = self._estimate_value( + data.year, + next_point[0], + next_point[1].value, + second_next_point[0], + second_next_point[1].value, + ) + continue + + # If we don't have a next point, try extrapolating with the previous two points + if previous_point is not None: + # Check if we have a second data point + second_previous_point = self._get_previous_value( + previous_point[2], metric_id + ) + + # If we don't have a second next data point, we have to use the next one as is + if second_previous_point is None: + metric_value.value = previous_point[1].value + continue + # Otherwise we can extrapolate + metric_value.value = self._estimate_value( + data.year, + second_previous_point[0], + second_previous_point[1].value, + previous_point[0], + previous_point[1].value, + ) + + def _get_previous_value( + self, year_index: int, metric_id: str + ) -> tuple[int, MetricValue, int] | None: + for index in range(year_index - 1, -1, -1): + data = self.yearly_data[index] + metric_value = data.metric_data.get(metric_id, None) + if metric_value is not None and not metric_value.is_estimate: + return (data.year, metric_value, index) + return None + + def _get_next_value( + self, year_index: int, metric_id: str + ) -> tuple[int, MetricValue, int] | None: + for index in range(year_index + 1, len(self.yearly_data)): + data = self.yearly_data[index] + metric_value = data.metric_data.get(metric_id, None) + if metric_value is not None and not metric_value.is_estimate: + return (data.year, metric_value, index) + + return None + + def _estimate_value( + self, x: float, x_1: float, y_1: float, x_2: float, y_2: float + ) -> float: + slope = (y_2 - y_1) / (x_2 - x_1) + return slope * (x - x_1) + y_1 + + def estimate_tipping_point(self, metric_id: str, metric_value: float) -> float: + if len(self.yearly_data) == 0: + return 0 + + if len(self.yearly_data) == 1: + return self.yearly_data[0].year + + # Find the global min and max to establish the bounds + global_min: tuple[float, float] = (0, 0) + has_global_min = False + global_max: tuple[float, float] = (0, 0) + has_global_max = False + + for year_data in self.yearly_data: + year_value = year_data.metric_data.get(metric_id, None) + if year_value is None: + continue + + if not has_global_min or year_value.value < global_min[1]: + has_global_min = True + global_min = (year_data.year, year_value.value) + + if not has_global_max or year_value.value > global_max[1]: + has_global_max = True + global_max = (year_data.year, year_value.value) + + # That means we don't have any valid data points for this metric + if not has_global_min or not has_global_max: + return 0 + + if metric_value <= global_min[1]: + return global_min[0] + + if metric_value >= global_max[1]: + return global_max[0] + + for index, year_data in enumerate(self.yearly_data): + if index + 1 >= len(self.yearly_data): + continue + + year_value = year_data.metric_data.get(metric_id, None) + if year_value is None: + continue + + next_year_data = self.yearly_data[index + 1] + next_year_value = next_year_data.metric_data.get(metric_id, None) + + if next_year_value is None: + continue + + min_year, min_value, max_year, max_value = ( + ( + year_data.year, + year_value.value, + next_year_data.year, + next_year_value.value, + ) + if year_value.value <= next_year_value.value + else ( + next_year_data.year, + next_year_value.value, + year_data.year, + year_value.value, + ) + ) + + if metric_value < min_value or metric_value > max_value: + continue + + return self._estimate_value( + metric_value, min_value, float(min_year), max_value, float(max_year) + ) + + # We should never get here, but just in case + return 0 diff --git a/source/package/adaptation_pathways/app/model/sorting.py b/source/package/adaptation_pathways/app/model/sorting.py index 0bd26bc..a3467f3 100644 --- a/source/package/adaptation_pathways/app/model/sorting.py +++ b/source/package/adaptation_pathways/app/model/sorting.py @@ -1,24 +1,8 @@ -from enum import Enum - - -class SortTarget(Enum): - NONE = 0 - ATTRIBUTE = 1 - METRIC = 2 - - class SortingInfo: def __init__( self, - ids: list[str], - target=SortTarget.NONE, sort_key: str | None = None, ascending=True, ): - self.sorted_ids = ids - self.target = target self.sort_key = sort_key self.ascending = ascending - - def sort_by_id(self): - self.sorted_ids.sort(reverse=not self.ascending) diff --git a/source/package/adaptation_pathways/app/service/pathway_service.py b/source/package/adaptation_pathways/app/service/pathway_service.py index 4dbfccb..f63caf8 100644 --- a/source/package/adaptation_pathways/app/service/pathway_service.py +++ b/source/package/adaptation_pathways/app/service/pathway_service.py @@ -29,21 +29,3 @@ def generate_pathways( # Generate all pathways that don't violate the provided constraints # Estimate a value for each metric based on its Metric.estimate method return [] - - @staticmethod - def estimate_metric( - pathway: Pathway, - metric: Metric, - all_actions: list[Action], - all_pathways: list[Pathway], - ) -> float: - # For manual estimate, just use the existing data (if there is any) - if metric.estimate is MetricEstimate.MANUAL: - current_data = pathway.metric_data.get(metric) - if current_data is None or current_data.is_estimate: - return 0 - - return current_data.value - - # Do the proper estimate respecting the Metric.estimate method - return 0 diff --git a/source/package/adaptation_pathways/app/service/plotting_service.py b/source/package/adaptation_pathways/app/service/plotting_service.py index 19a69c1..f32ef9a 100644 --- a/source/package/adaptation_pathways/app/service/plotting_service.py +++ b/source/package/adaptation_pathways/app/service/plotting_service.py @@ -11,7 +11,9 @@ from adaptation_pathways.action import Action from adaptation_pathways.alias import Sequence, TippingPointByAction +from adaptation_pathways.app.model.pathway import Pathway from adaptation_pathways.app.model.pathways_project import PathwaysProject +from adaptation_pathways.app.model.scenario import Scenario from adaptation_pathways.graph.convert import sequence_graph_to_pathway_map from adaptation_pathways.graph.node.action import Action as ActionNode from adaptation_pathways.graph.sequence_graph import SequenceGraph @@ -31,25 +33,39 @@ def draw_metro_map( action_nodes: dict[str, ActionNode] = {} tipping_points: TippingPointByAction = {} action_colors: dict[str, str] = {} + root_pathway = project.root_pathway metric = project.graph_metric + scenario: Scenario | None = ( + None if not project.graph_is_time else project.graph_scenario + ) + + def get_tipping_point(pathway: Pathway) -> float: + metric_value = pathway.metric_data.get(metric.id, None) + if metric_value is None: + metric_value = root_pathway.metric_data.get(metric.id, None) + + if metric_value is None: + return 0 + + if scenario is not None: + return scenario.estimate_tipping_point(metric.id, metric_value.value) + + return metric_value.value # Create action node for each pathway - for pathway in project.sorted_pathways: + for pathway in project.all_pathways: pathway_action = project.get_action(pathway.action_id) action = Action(pathway_action.name) action_node = ActionNode(action) action_nodes[pathway.id] = action_node action_colors[action.name] = pathway_action.color - - metric_value = pathway.metric_data.get(metric.id, None) - value = metric.current_value if metric_value is None else metric_value.value - tipping_points[action] = value + tipping_points[action] = get_tipping_point(pathway) # Populate sequences sequence_graph = SequenceGraph() sequences: list[Sequence] = [] - for pathway in project.sorted_pathways: + for pathway in project.all_pathways: if pathway.parent_id is None: continue diff --git a/source/package/adaptation_pathways/app/service/project_service.py b/source/package/adaptation_pathways/app/service/project_service.py new file mode 100644 index 0000000..728d47a --- /dev/null +++ b/source/package/adaptation_pathways/app/service/project_service.py @@ -0,0 +1,25 @@ +import base64 + +import jsonpickle + +from ..model.pathways_project import PathwaysProject + + +class ProjectService: + @staticmethod + def to_json(project: PathwaysProject) -> str: + text: str = jsonpickle.encode(project) + return text + + @staticmethod + def from_json(project_json: str) -> PathwaysProject: + project = jsonpickle.decode(project_json) + return project + + @staticmethod + def to_data_url(project: PathwaysProject) -> str: + text = ProjectService.to_json(project) + text_bytes = text.encode("utf-8") + text_64_bytes = base64.b64encode(text_bytes) + text_64_str = text_64_bytes.decode("utf-8") + return f"data:text/plain;base64,{text_64_str}" diff --git a/source/package/adaptation_pathways/app/service/scenario_service.py b/source/package/adaptation_pathways/app/service/scenario_service.py index 2ab7163..29e4f5b 100644 --- a/source/package/adaptation_pathways/app/service/scenario_service.py +++ b/source/package/adaptation_pathways/app/service/scenario_service.py @@ -9,4 +9,4 @@ def estimate_metric_at_time( metric: Metric, time: float, scenario: Scenario ) -> float: # Replace with a linear interpolation/extrapolation using the nearest points - return metric.current_value + return 0 diff --git a/source/package/pathways_app/CMakeLists.txt b/source/package/pathways_app/CMakeLists.txt index 88bf2cc..406c7ed 100644 --- a/source/package/pathways_app/CMakeLists.txt +++ b/source/package/pathways_app/CMakeLists.txt @@ -11,6 +11,10 @@ add_custom_target(web_app COMMAND ${CMAKE_COMMAND} -E env "PIP_FIND_LINKS=file://${PROJECT_BINARY_DIR}/dist" ${Flet_EXECUTABLE} build web ${CMAKE_CURRENT_BINARY_DIR} + COMMAND + ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/patch_index_html.py + ${CMAKE_CURRENT_BINARY_DIR}/build/web/index.html COMMAND ${CMAKE_COMMAND} -E echo "Run a command like this to start the web app:" diff --git a/source/package/pathways_app/pathways_app/__init__.py b/source/package/pathways_app/__init__.py similarity index 100% rename from source/package/pathways_app/pathways_app/__init__.py rename to source/package/pathways_app/__init__.py diff --git a/source/package/pathways_app/assets/js/pathways.js b/source/package/pathways_app/assets/js/pathways.js new file mode 100644 index 0000000..1c5dd63 --- /dev/null +++ b/source/package/pathways_app/assets/js/pathways.js @@ -0,0 +1,61 @@ +setTimeout(function () { + var oldMessageHandler = pythonWorker.onmessage; + pythonWorker.onmessage = function (message) { + try { + msgObj = JSON.parse(message.data); + switch (msgObj.action) { + case "open_project": + openFile(); + break; + case "save_project": + saveFile(msgObj.content, msgObj.filename); + break; + default: + oldMessageHandler(message); + break; + } + } catch (e) { + console.error(e); + oldMessageHandler(message); + } + }; +}); + +var file_open_input = document.createElement("input"); +file_open_input.type = "file"; +file_open_input.accept = ".pwproj"; +file_open_input.multiple = false; + +function openFile() { + file_open_input.onchange = (e) => { + if (e.target.files.length == 0) return; + + var file = e.target.files[0]; + + var reader = new FileReader(); + reader.readAsText(file, "UTF-8"); + + reader.onload = (readerEvent) => { + var content = readerEvent.target.result; + pythonWorker.postMessage( + JSON.stringify({ + action: "open_project_result", + payload: content, + }) + ); + }; + }; + + file_open_input.click(); +} + +var file_download_link = document.createElement("a"); + +function saveFile(data, filename) { + var blob = new Blob([data], { + type: "text/plain;charset=utf-8", + }); + file_download_link.href = URL.createObjectURL(blob); + file_download_link.download = filename; + file_download_link.click(); +} diff --git a/source/package/pathways_app/main.py b/source/package/pathways_app/main.py index b80c27a..ae8bd6b 100644 --- a/source/package/pathways_app/main.py +++ b/source/package/pathways_app/main.py @@ -1,10 +1,15 @@ import logging import flet as ft -from pathways_app.cli.app import main +from src.cli.app import main logging.basicConfig(level=logging.CRITICAL) -ft.app(target=main, assets_dir="assets") +ft.app( + target=main, + assets_dir="assets", + view=ft.AppView.FLET_APP, + route_url_strategy="hash", +) print("Pathways App Started") diff --git a/source/package/pathways_app/patch_index_html.py b/source/package/pathways_app/patch_index_html.py new file mode 100644 index 0000000..36dda2a --- /dev/null +++ b/source/package/pathways_app/patch_index_html.py @@ -0,0 +1,44 @@ +import sys +from pathlib import Path + +import docopt + + +def main() -> int: + command = Path(sys.argv[0]).name + usage = f"""\ +Patch web app's index.html + +Usage: + {command} + +Arguments: + pathname Pathname of index.html file to patch + +Options: + -h --help Show this screen and exit +""" + status = 1 + + try: + arguments = sys.argv[1:] + arguments = docopt.docopt(usage, arguments) + html_pathname = arguments[""] # type: ignore + + search_for = '' + replace_with = f'{search_for}\n ' + + path = Path(html_pathname) + content = path.read_text(encoding="utf-8") + content = content.replace(search_for, replace_with) + path.write_text(content, encoding="utf-8") + + status = 0 + except Exception as exception: + sys.stderr.write(f"{exception}\n") + + return status + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/source/package/pathways_app/pathways_app/cli/app.py b/source/package/pathways_app/pathways_app/cli/app.py deleted file mode 100644 index c86ef33..0000000 --- a/source/package/pathways_app/pathways_app/cli/app.py +++ /dev/null @@ -1,135 +0,0 @@ -import locale - -import flet as ft - -from .. import example, theme -from ..controls.actions_panel import ActionsPanel -from ..controls.graph_panel import GraphPanel -from ..controls.header import SectionHeader -from ..controls.menu_bar import MenuBar -from ..controls.metrics_panel import MetricsPanel -from ..controls.panel import Panel -from ..controls.pathways_panel import PathwaysPanel -from ..controls.scenarios_panel import ScenariosPanel -from ..controls.tabbed_panel import TabbedPanel - - -locale.setlocale(locale.LC_ALL, "") - - -def main(page: ft.Page): - page.theme = theme.theme - page.theme_mode = ft.ThemeMode.LIGHT - - # bitdojo_window could make a custom title bar - # page.window.frameless = True - - page.window.width = 1200 - page.window.height = 800 - page.window.resizable = True - - page.title = "Pathways Generator" - page.fonts = theme.fonts - page.bgcolor = theme.colors.primary_darker - page.padding = 1 - page.spacing = 0 - - project = example.project - - page.appbar = MenuBar(project) - metrics_panel = MetricsPanel(project) - metrics_tab = (SectionHeader(ft.icons.TUNE, "Metrics"), metrics_panel) - - actions_panel = ActionsPanel(project) - actions_tab = ( - SectionHeader(ft.icons.CONSTRUCTION_OUTLINED, "Actions"), - actions_panel, - ) - - scenarios_panel = ScenariosPanel(project) - scenarios_tab = ( - SectionHeader(ft.icons.PUBLIC, "Scenarios"), - scenarios_panel, - ) - - graph_panel = GraphPanel(project) - pathways_panel = PathwaysPanel(project) - - def on_metrics_changed(): - metrics_panel.redraw() - scenarios_panel.redraw() - actions_panel.redraw() - pathways_panel.redraw() - graph_panel.redraw() - - def on_scenarios_changed(): - scenarios_panel.redraw() - graph_panel.redraw() - - def on_actions_changed(): - actions_panel.redraw() - pathways_panel.redraw() - graph_panel.redraw() - - def on_pathways_changed(): - pathways_panel.redraw() - graph_panel.redraw() - - def on_action_color_changed(): - pathways_panel.redraw() - graph_panel.redraw() - - # def on_graph_changed(): - # graph_panel.redraw() - - project.on_conditions_changed.append(on_metrics_changed) - project.on_criteria_changed.append(on_metrics_changed) - project.on_scenarios_changed.append(on_scenarios_changed) - project.on_actions_changed.append(on_actions_changed) - project.on_action_color_changed.append(on_action_color_changed) - project.on_pathways_changed.append(on_pathways_changed) - - page.add( - ft.Container( - expand=True, - padding=theme.variables.panel_spacing, - content=ft.Row( - expand=True, - spacing=theme.variables.panel_spacing, - controls=[ - ft.Column( - expand=2, - spacing=theme.variables.panel_spacing, - horizontal_alignment=ft.CrossAxisAlignment.STRETCH, - controls=[ - TabbedPanel( - selected_index=0, - tabs=[metrics_tab, actions_tab, scenarios_tab], - ) - ], - ), - ft.Column( - expand=3, - spacing=theme.variables.panel_spacing, - horizontal_alignment=ft.CrossAxisAlignment.STRETCH, - controls=[ - Panel(graph_panel), - Panel( - content=ft.Column( - expand=False, - alignment=ft.MainAxisAlignment.START, - spacing=15, - controls=[ - pathways_panel, - ], - ), - padding=theme.variables.panel_padding, - ), - ], - ), - ], - ), - bgcolor=theme.colors.primary_lighter, - border_radius=ft.border_radius.only(bottom_left=8, bottom_right=8), - ) - ) diff --git a/source/package/pathways_app/pathways_app/controls/__init__.py b/source/package/pathways_app/pathways_app/controls/__init__.py deleted file mode 100644 index 800b7ae..0000000 --- a/source/package/pathways_app/pathways_app/controls/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Customized Flet controls -""" diff --git a/source/package/pathways_app/pathways_app/controls/actions_panel.py b/source/package/pathways_app/pathways_app/controls/actions_panel.py deleted file mode 100644 index 7abce25..0000000 --- a/source/package/pathways_app/pathways_app/controls/actions_panel.py +++ /dev/null @@ -1,249 +0,0 @@ -# pylint: disable=too-many-arguments,too-many-instance-attributes -import random - -import flet as ft - -from adaptation_pathways.app.model.action import Action -from adaptation_pathways.app.model.pathways_project import PathwaysProject -from adaptation_pathways.app.model.sorting import SortTarget - -from .. import theme -from .action_icon import ActionIcon -from .editable_cell import EditableTextCell -from .metric_effect import MetricEffectCell -from .metric_value import MetricValueCell -from .sortable_header import SortableHeader, SortMode -from .styled_button import StyledButton -from .styled_table import StyledTable - - -class ActionsPanel(ft.Column): - def __init__(self, project: PathwaysProject): - super().__init__( - expand=True, - horizontal_alignment=ft.CrossAxisAlignment.STRETCH, - spacing=40, - scroll=ft.ScrollMode.AUTO, - ) - - self.project = project - - self.action_table = StyledTable( - columns=[], rows=[], row_height=42, show_checkboxes=True - ) - - self.delete_action_button = StyledButton( - "Delete", - icon=ft.icons.DELETE, - on_click=self.on_delete_actions, - ) - - self.update_table() - - self.controls = [ - ft.Column( - expand=False, - horizontal_alignment=ft.CrossAxisAlignment.STRETCH, - controls=[ - ft.Row( - expand=True, - controls=[ - ft.Container(expand=True), - self.delete_action_button, - StyledButton( - "New", - icon=ft.icons.ADD_CIRCLE_OUTLINE, - on_click=self.on_new_action, - ), - ], - ), - self.action_table, - ], - ), - ] - - def redraw(self): - self.update_table() - # self.update() - - def on_name_edited(self, _): - self.project.notify_actions_changed() - self.update() - - def on_cell_edited(self, cell: MetricValueCell): - self.project.update_pathway_values(cell.metric.id) - self.project.notify_actions_changed() - self.update() - - def on_action_selected(self, action: Action): - if action.id in self.project.selected_action_ids: - self.project.selected_action_ids.remove(action.id) - else: - self.project.selected_action_ids.add(action.id) - self.redraw() - self.update() - - def on_sort_actions(self, header: SortableHeader): - if header.sort_mode == SortMode.NONE: - self.project.action_sorting.target = SortTarget.NONE - self.project.action_sorting.sort_key = None - self.project.action_sorting.ascending = True - else: - self.project.action_sorting.target = ( - SortTarget.ATTRIBUTE if header.sort_key == "name" else SortTarget.METRIC - ) - self.project.action_sorting.sort_key = header.sort_key - self.project.action_sorting.ascending = ( - header.sort_mode == SortMode.ASCENDING - ) - - self.project.sort_actions() - self.project.notify_actions_changed() - self.update() - - def on_delete_actions(self, _): - self.project.delete_selected_actions() - self.project.notify_actions_changed() - self.update() - - def on_new_action(self, _): - self.project.create_action( - random.choice(theme.action_colors), - random.choice(theme.action_icons), - ) - self.project.notify_actions_changed() - self.update() - - def update_table(self): - sort_mode = SortableHeader.get_sort_mode(self.project.action_sorting) - sort_key = self.project.action_sorting.sort_key - - sortable_headers = [ - SortableHeader( - sort_key="name", - name="Name", - sort_mode=SortMode.NONE if sort_key != "name" else sort_mode, - on_sort=self.on_sort_actions, - ), - *( - SortableHeader( - sort_key=metric.id, - name=metric.name, - sort_mode=SortMode.NONE if sort_key is not metric.id else sort_mode, - on_sort=self.on_sort_actions, - ) - for metric in self.project.all_metrics() - ), - ] - - columns = [ - ft.DataColumn( - label=ft.Text("Icon", expand=True), - ), - *( - ft.DataColumn(label=header, numeric=header.sort_key != "name") - for header in sortable_headers - ), - ] - self.action_table.set_columns(columns) - - self.update_rows() - self.delete_action_button.visible = len(self.project.selected_action_ids) > 0 - - def create_icon_editor(self, action: Action): - def on_color_picked(color: str): - action.color = color - action_icon.update_action(action) - self.project.notify_action_color_changed() - - def on_icon_picked(icon: str): - action.icon = icon - action_icon.update_action(action) - self.project.notify_action_color_changed() - - def on_editor_closed(_): - self.project.notify_actions_changed() - - def update_items(): - action_button.items = [ - ft.PopupMenuItem( - content=ft.GridView( - spacing=4, - width=200, - runs_count=4, - padding=ft.padding.symmetric(4, 6), - child_aspect_ratio=1.0, - controls=[ - ft.Container( - bgcolor=color, - border=ft.border.all( - 2, - ( - theme.colors.primary_medium - if action.color == color - else theme.colors.primary_lightest - ), - ), - on_click=lambda e, c=color: on_color_picked(c), - width=10, - height=10, - ) - for color in theme.action_colors - ], - ) - ), - ft.PopupMenuItem( - content=ft.GridView( - expand=True, - runs_count=5, - padding=ft.padding.symmetric(12, 6), - controls=[ - ft.Container( - ft.Icon(icon), - on_click=lambda e, i=icon: on_icon_picked(i), - ) - for icon in theme.action_icons - ], - spacing=4, - ) - ), - ] - - action_icon = ActionIcon(action) - - action_button = ft.PopupMenuButton( - action_icon, - items=[], - bgcolor=theme.colors.off_white, - menu_position=ft.PopupMenuPosition.UNDER, - on_cancel=on_editor_closed, - ) - update_items() - return action_button - - def update_rows(self): - rows = [] - - for action in self.project.sorted_actions: - metric_cells = [] - for metric in self.project.all_metrics(): - effect = action.metric_data[metric.id] - metric_cells.append( - MetricEffectCell( - metric, effect, on_finished_editing=self.on_cell_edited - ) - ) - - rows.append( - ft.DataRow( - [ - ft.DataCell(self.create_icon_editor(action)), - EditableTextCell(action, "name", self.on_name_edited), - *metric_cells, - ], - selected=action.id in self.project.selected_action_ids, - on_select_changed=lambda _, a=action: self.on_action_selected(a), - ) - ) - - self.action_table.set_rows(rows) diff --git a/source/package/pathways_app/pathways_app/controls/editable_cell.py b/source/package/pathways_app/pathways_app/controls/editable_cell.py deleted file mode 100644 index 21f7175..0000000 --- a/source/package/pathways_app/pathways_app/controls/editable_cell.py +++ /dev/null @@ -1,125 +0,0 @@ -from abc import ABC -from typing import Callable - -import flet as ft -from pyparsing import abstractmethod - -from .. import theme - - -class EditableCell(ft.DataCell, ABC): - def __init__( - self, - display_control: ft.Control, - edit_control: ft.Control, - is_calculated=False, - is_editing=False, - on_finished_editing: Callable[["EditableCell"], None] | None = None, - ): - self.display_content = display_control - self.input_content = edit_control - self.is_calculated = is_calculated - self.is_editing = is_editing - self.on_finished_editing = on_finished_editing - - self.display_content.visible = not self.is_editing - self.input_content.visible = self.is_editing - - self.cell_content = ft.Container( - expand=True, - content=ft.Stack([self.display_content, self.input_content]), - on_click=self.toggle_editing, - ) - - self.update_bg() - - super().__init__(content=self.cell_content) - - def toggle_editing(self, _): - self.is_editing = not self.is_editing - - if self.is_editing: - self.update_input() - else: - self.update_display() - - self.display_content.visible = not self.is_editing - self.input_content.visible = self.is_editing - - self.update_bg() - self.update() - - if self.is_editing: - self.input_content.focus() - else: - if self.on_finished_editing is not None: - self.on_finished_editing(self) - - def update_bg(self): - self.cell_content.bgcolor = ( - theme.colors.calculated_bg - if self.is_calculated and not self.is_editing - else None - ) - - def set_calculated(self, is_calculated): - self.is_calculated = is_calculated - self.update_bg() - self.update() - - @abstractmethod - def update_display(self): - pass - - @abstractmethod - def update_input(self): - pass - - -class EditableTextCell(EditableCell): - def __init__(self, source: object, value_attribute: str, on_finished_editing=None): - self.source = source - self.value_attribute = value_attribute - - self.display_content = ft.Text(self.value, expand=True) - self.input_content = ft.TextField( - dense=True, - enable_suggestions=False, - value=self.value, - keyboard_type=ft.KeyboardType.TEXT, - bgcolor=theme.colors.true_white, - border_color=theme.colors.primary_medium, - focused_border_color=theme.colors.primary_light, - cursor_color=theme.colors.primary_medium, - text_style=theme.text.textfield, - prefix_style=theme.text.textfield_symbol, - suffix_style=theme.text.textfield_symbol, - expand=True, - content_padding=ft.padding.symmetric(4, 6), - on_blur=self.toggle_editing, - ) - - def on_finished_editing_internal(_): - self.value = self.input_content.value - if on_finished_editing is not None: - on_finished_editing(self) - - super().__init__( - self.display_content, - self.input_content, - on_finished_editing=on_finished_editing_internal, - ) - - @property - def value(self) -> str: - return getattr(self.source, self.value_attribute) - - @value.setter - def value(self, value: str): - setattr(self.source, self.value_attribute, value) - - def update_input(self): - self.input_content.value = self.display_content.value - - def update_display(self): - self.display_content.value = self.input_content.value diff --git a/source/package/pathways_app/pathways_app/controls/graph_panel.py b/source/package/pathways_app/pathways_app/controls/graph_panel.py deleted file mode 100644 index a24507b..0000000 --- a/source/package/pathways_app/pathways_app/controls/graph_panel.py +++ /dev/null @@ -1,96 +0,0 @@ -import flet as ft -import matplotlib.pyplot -from flet.matplotlib_chart import MatplotlibChart - -from adaptation_pathways.app.model.pathways_project import PathwaysProject -from adaptation_pathways.app.service.plotting_service import PlottingService - -from .. import theme -from .styled_button import StyledButton -from .styled_dropdown import StyledDropdown - - -class GraphPanel(ft.Row): - def __init__(self, project: PathwaysProject): - super().__init__(expand=False, spacing=0) - - self.project = project - - self.graph_container = ft.Container( - expand=True, bgcolor=theme.colors.true_white - ) - - self.metric_dropdown = StyledDropdown( - value="", - options=[], - width=200, - ) - - self.update_parameters() - - self.controls = [ - ft.Container( - expand=False, - padding=theme.variables.panel_padding, - content=ft.Column( - expand=False, - width=200, - horizontal_alignment=ft.CrossAxisAlignment.STRETCH, - controls=[ - StyledDropdown( - "Metro Map", - options=[ - ft.dropdown.Option("Metro Map"), - ft.dropdown.Option("Bar Chart"), - ], - option_icons=[ft.icons.ROUTE_OUTLINED, ft.icons.BAR_CHART], - height=36, - text_style=theme.text.dropdown_large, - ), - ft.Container(expand=True), - ft.Row( - [ - StyledButton("Export", icon=ft.icons.SAVE_SHARP), - ], - alignment=ft.MainAxisAlignment.END, - ), - ], - ), - ), - ft.Container( - expand=True, - bgcolor=theme.colors.true_white, - border_radius=ft.border_radius.only( - top_right=theme.variables.small_radius, - bottom_right=theme.variables.small_radius, - ), - padding=ft.padding.only(bottom=10), - content=ft.Column( - [self.graph_container, self.metric_dropdown], - horizontal_alignment=ft.CrossAxisAlignment.CENTER, - ), - ), - ] - - self.update_graph() - - def redraw(self): - self.update_parameters() - self.update_graph() - self.update() - - def update_parameters(self): - self.metric_dropdown.options = [ - *( - ft.dropdown.Option( - key=metric.id, text=f"{metric.name} ({metric.unit.symbol})" - ) - for metric in self.project.sorted_conditions - ), - ] - self.metric_dropdown.value = self.project.graph_metric_id - - def update_graph(self): - figure, _ = PlottingService.draw_metro_map(self.project) - self.graph_container.content = MatplotlibChart(figure) - matplotlib.pyplot.close(figure) diff --git a/source/package/pathways_app/pathways_app/controls/menu_bar.py b/source/package/pathways_app/pathways_app/controls/menu_bar.py deleted file mode 100644 index 46003a3..0000000 --- a/source/package/pathways_app/pathways_app/controls/menu_bar.py +++ /dev/null @@ -1,62 +0,0 @@ -import flet as ft - -from adaptation_pathways.app.model import PathwaysProject - -from .. import theme - - -class MenuBar(ft.Container): - def __init__(self, project: PathwaysProject): - super().__init__( - content=ft.Stack( - [ - ft.Row( - [ - ft.Image(theme.icon), - ft.Text("PATHWAYS\nGENERATOR", style=theme.text.logo), - ] - ), - ft.Row( - [ - ft.Container( - bgcolor=theme.colors.primary_medium, - border_radius=theme.variables.small_radius, - alignment=ft.alignment.center, - padding=ft.padding.symmetric(0, 15), - width=300, - content=ft.Stack( - [ - ft.Column( - controls=[ - ft.Text( - project.name, - color=theme.colors.true_white, - ), - ft.Text( - project.organization, - text_align=ft.TextAlign.CENTER, - color=theme.colors.true_white, - ), - ], - spacing=0, - alignment=ft.MainAxisAlignment.CENTER, - horizontal_alignment=ft.CrossAxisAlignment.CENTER, - ) - ] - ), - ) - ], - alignment=ft.MainAxisAlignment.CENTER, - vertical_alignment=ft.MainAxisAlignment.CENTER, - ), - ] - ), - height=50, - padding=ft.padding.symmetric(4, 5), - margin=0, - bgcolor=theme.colors.primary_dark, - border_radius=ft.border_radius.only(top_left=0, top_right=0), - border=ft.border.only( - bottom=ft.border.BorderSide(1, theme.colors.primary_darker) - ), - ) diff --git a/source/package/pathways_app/pathways_app/controls/metrics_panel.py b/source/package/pathways_app/pathways_app/controls/metrics_panel.py deleted file mode 100644 index 95c1195..0000000 --- a/source/package/pathways_app/pathways_app/controls/metrics_panel.py +++ /dev/null @@ -1,179 +0,0 @@ -from typing import Callable - -import flet as ft - -from adaptation_pathways.app.model.metric import Metric -from adaptation_pathways.app.model.pathways_project import PathwaysProject - -from .editable_cell import EditableTextCell -from .header import SmallHeader -from .styled_button import StyledButton -from .styled_table import StyledTable -from .unit_cell import MetricUnitCell - - -class MetricsPanel(ft.Column): - def __init__(self, project: PathwaysProject): - super().__init__() - - self.project = project - self.expand = False - self.horizontal_alignment = ft.CrossAxisAlignment.STRETCH - self.spacing = 40 - - self.conditions_table = StyledTable( - columns=[ - ft.DataColumn(label=ft.Text("Name")), - ft.DataColumn(label=ft.Text("Unit")), - ], - rows=[], - show_checkboxes=True, - ) - - self.criteria_table = StyledTable( - columns=[ - ft.DataColumn(label=ft.Text("Name")), - ft.DataColumn(label=ft.Text("Unit")), - ], - rows=[], - show_checkboxes=True, - ) - - self.delete_condition_button = StyledButton( - "Delete", ft.icons.DELETE, self.on_delete_conditions - ) - self.delete_criteria_button = StyledButton( - "Delete", ft.icons.DELETE, self.on_delete_criteria - ) - - self.update_metrics() - - self.controls = [ - ft.Column( - expand=False, - horizontal_alignment=ft.CrossAxisAlignment.STRETCH, - controls=[ - ft.Row( - expand=False, - controls=[ - SmallHeader("Conditions"), - ft.Container(expand=True), - self.delete_condition_button, - StyledButton( - "New", - icon=ft.icons.ADD_CIRCLE_OUTLINE, - on_click=self.on_new_condition, - ), - ], - ), - self.conditions_table, - ], - ), - ft.Column( - expand=False, - horizontal_alignment=ft.CrossAxisAlignment.STRETCH, - controls=[ - ft.Row( - expand=False, - controls=[ - SmallHeader("Criteria"), - ft.Container(expand=True), - self.delete_criteria_button, - StyledButton( - text="New", - icon=ft.icons.ADD_CIRCLE_OUTLINE, - on_click=self.on_new_criteria, - ), - ], - ), - self.criteria_table, - ], - ), - ] - - def redraw(self): - self.update_metrics() - self.update() - - def on_metric_updated(self, _): - print(self) - self.project.notify_conditions_changed() - - def on_condition_selected(self, metric: Metric): - if metric.id in self.project.selected_condition_ids: - self.project.selected_condition_ids.remove(metric.id) - else: - self.project.selected_condition_ids.add(metric.id) - - self.redraw() - - def on_criteria_selected(self, metric: Metric): - if metric.id in self.project.selected_criteria_ids: - self.project.selected_criteria_ids.remove(metric.id) - else: - self.project.selected_criteria_ids.add(metric.id) - - self.redraw() - - def on_new_condition(self, _): - metric = self.project.create_condition() - self.project.update_pathway_values(metric.id) - self.project.notify_conditions_changed() - - def on_delete_conditions(self, _): - for metric_id in self.project.selected_condition_ids: - self.project.delete_condition(metric_id) - self.project.notify_conditions_changed() - - def on_new_criteria(self, _): - metric = self.project.create_criteria() - self.project.update_pathway_values(metric.id) - self.project.notify_criteria_changed() - - def on_delete_criteria(self, _): - for metric_id in self.project.selected_criteria_ids: - self.project.delete_criteria(metric_id) - self.project.notify_criteria_changed() - - def get_metric_row( - self, - metric: Metric, - selected_ids: set[str], - on_metric_selected: Callable[[Metric], None], - ) -> ft.DataRow: - row = ft.DataRow( - [ - EditableTextCell(metric, "name", self.on_metric_updated), - MetricUnitCell(metric, self.on_metric_updated), - ], - selected=metric.id in selected_ids, - ) - row.on_select_changed = lambda e: on_metric_selected(metric) - return row - - def update_metrics(self): - self.conditions_table.set_rows( - [ - self.get_metric_row( - metric, - self.project.selected_condition_ids, - self.on_condition_selected, - ) - for metric in self.project.sorted_conditions - ] - ) - has_selected_conditions = len(self.project.selected_condition_ids) > 0 - self.delete_condition_button.visible = has_selected_conditions - - self.criteria_table.set_rows( - [ - self.get_metric_row( - metric, - self.project.selected_criteria_ids, - self.on_criteria_selected, - ) - for metric in self.project.sorted_criteria - ] - ) - has_selected_criteria = len(self.project.selected_criteria_ids) > 0 - self.delete_criteria_button.visible = has_selected_criteria diff --git a/source/package/pathways_app/pathways_app/controls/pathways_panel.py b/source/package/pathways_app/pathways_app/controls/pathways_panel.py deleted file mode 100644 index e175f2b..0000000 --- a/source/package/pathways_app/pathways_app/controls/pathways_panel.py +++ /dev/null @@ -1,225 +0,0 @@ -import flet as ft - -from adaptation_pathways.app.model.pathway import Pathway -from adaptation_pathways.app.model.pathways_project import PathwaysProject -from adaptation_pathways.app.model.sorting import SortTarget - -from .. import theme -from .action_icon import ActionIcon -from .header import SectionHeader -from .metric_value import MetricValueCell -from .sortable_header import SortableHeader, SortMode -from .styled_button import StyledButton -from .styled_table import StyledTable - - -class PathwaysPanel(ft.Column): - def __init__(self, project: PathwaysProject): - self.project = project - - self.rows_by_pathway: dict[Pathway, ft.DataRow] = {} - - self.pathway_table = StyledTable(columns=[], rows=[], show_checkboxes=True) - self.delete_pathways_button = StyledButton( - "Delete", ft.icons.DELETE, self.on_delete_pathways - ) - self.update_table() - - super().__init__( - expand=True, - scroll=ft.ScrollMode.AUTO, - controls=[ - ft.Column( - expand=False, - horizontal_alignment=ft.CrossAxisAlignment.STRETCH, - controls=[ - ft.Row( - [ - SectionHeader( - ft.icons.ACCOUNT_TREE_OUTLINED, "Pathways" - ), - ft.Container(expand=True), - self.delete_pathways_button, - ], - spacing=15, - ), - self.pathway_table, - ], - ) - ], - ) - - def redraw(self): - self.update_table() - self.update() - - def on_delete_pathways(self, _): - self.project.delete_selected_pathways() - self.project.notify_pathways_changed() - - def on_sort_table(self, header: SortableHeader): - if header.sort_mode is SortMode.NONE: - self.project.pathway_sorting.target = SortTarget.NONE - self.project.pathway_sorting.sort_key = None - self.project.pathway_sorting.ascending = True - else: - self.project.pathway_sorting.target = SortTarget.METRIC - self.project.pathway_sorting.sort_key = header.sort_key - self.project.pathway_sorting.ascending = ( - header.sort_mode == SortMode.ASCENDING - ) - - self.project.sort_pathways() - self.project.notify_pathways_changed() - - def update_table(self): - sorting = self.project.pathway_sorting - sort_mode = SortableHeader.get_sort_mode(sorting) - - self.delete_pathways_button.visible = ( - len(self.project.selected_pathway_ids) > 0 - and not self.project.root_pathway_id in self.project.selected_pathway_ids - ) - - self.pathway_table.set_columns( - [ - ft.DataColumn(ft.Text("Pathway")), - *( - ft.DataColumn( - SortableHeader( - metric.id, - metric.name, - sort_mode=( - SortMode.NONE - if sorting.sort_key is not metric.id - else sort_mode - ), - on_sort=self.on_sort_table, - ), - numeric=True, - ) - for metric in self.project.all_metrics() - ), - ] - ) - - rows = [] - - self.rows_by_pathway = {} - for pathway in self.project.sorted_pathways: - ancestors = self.project.get_ancestors_and_self(pathway) - path = [*reversed([*ancestors])] - # if len(path) > 1: - # path = path[1:] - row = self.get_pathway_row(pathway, path) - if pathway.id == self.project.root_pathway_id: - row.on_select_changed = None - self.rows_by_pathway[pathway] = row - rows.append(row) - - self.pathway_table.set_rows(rows) - return rows - - def on_metric_value_edited(self, cell: MetricValueCell): - self.project.update_pathway_values(cell.metric.id) - self.project.sort_pathways() - self.project.notify_pathways_changed() - - def get_pathway_row(self, pathway: Pathway, ancestors: list[Pathway]): - children = [*self.project.get_children(pathway.id)] - pathway_action = self.project.get_action(pathway.action_id) - - unused_action_ids = [ - action_id - for action_id in self.project.action_sorting.sorted_ids - if not any(ancestor.action_id == action_id for ancestor in ancestors) - and not any(child.action_id == action_id for child in children) - ] - - row_controls = [ - ActionIcon(self.project.get_action(ancestor.action_id), size=26) - for ancestor in ancestors - ] - - if pathway.parent_id is None: - row_controls.append(ft.Text(" Current ", color=pathway_action.color)) - - if len(unused_action_ids) > 0: - row_controls.append( - ft.PopupMenuButton( - ft.Icon( - ft.icons.ADD_CIRCLE_OUTLINE, - size=20, - color=theme.colors.primary_lightest, - ), - items=[ - ft.PopupMenuItem( - content=ft.Row( - [ - ActionIcon( - action, - display_tooltip=False, - ), - ft.Text( - action.name, - style=theme.text.normal, - ), - ] - ), - on_click=lambda e, action_id=action_id: self.extend_pathway( - pathway, action_id - ), - ) - for action_id in unused_action_ids - for action in [self.project.get_action(action_id)] - ], - tooltip=ft.Tooltip( - "Add", - bgcolor=theme.colors.primary_white, - ), - bgcolor=theme.colors.off_white, - menu_position=ft.PopupMenuPosition.UNDER, - ), - ) - - row = ft.DataRow( - [ - ft.DataCell( - ft.Container( - expand=True, - content=ft.Row( - spacing=0, - controls=row_controls, - ), - ), - ), - *( - MetricValueCell( - metric, - pathway.metric_data[metric.id], - on_finished_editing=self.on_metric_value_edited, - ) - for metric in self.project.all_metrics() - ), - ], - selected=pathway.id in self.project.selected_pathway_ids, - ) - - if pathway.parent_id is None: - row.color = "#EEEEEE" - - def on_select_changed(_): - if pathway.id in self.project.selected_pathway_ids: - self.project.selected_pathway_ids.remove(pathway.id) - else: - self.project.selected_pathway_ids.add(pathway.id) - - self.project.notify_pathways_changed() - - row.on_select_changed = on_select_changed - return row - - def extend_pathway(self, pathway: Pathway, action_id: str): - self.project.create_pathway(action_id, pathway.id) - self.project.sort_pathways() - self.project.notify_pathways_changed() diff --git a/source/package/pathways_app/pathways_app/controls/scenarios_panel.py b/source/package/pathways_app/pathways_app/controls/scenarios_panel.py deleted file mode 100644 index a935bd0..0000000 --- a/source/package/pathways_app/pathways_app/controls/scenarios_panel.py +++ /dev/null @@ -1,69 +0,0 @@ -import flet as ft - -from adaptation_pathways.app.model.pathways_project import PathwaysProject - -from .styled_button import StyledButton -from .styled_dropdown import StyledDropdown -from .styled_table import StyledTable - - -class ScenariosPanel(ft.Column): - def __init__(self, project: PathwaysProject): - super().__init__( - expand=True, - horizontal_alignment=ft.CrossAxisAlignment.STRETCH, - scroll=ft.ScrollMode.AUTO, - ) - - self.project = project - year_range = range(self.project.start_year, self.project.end_year) - current_scenario = self.project.get_scenario(self.project.selected_scenario_id) - if current_scenario is None: - return - - self.controls = [ - ft.Text("!! UNDER CONSTRUCTION !!", color="#FF0000"), - ft.Row( - expand=False, - controls=[ - StyledDropdown( - value=current_scenario.name, - options=[ - ft.dropdown.Option(key=scenario.id, text=scenario.name) - for scenario in self.project.sorted_scenarios - ], - ), - ft.Container(expand=True), - StyledButton("New", icon=ft.icons.ADD_CIRCLE_OUTLINE), - ], - ), - StyledTable( - columns=[ - ft.DataColumn(label=ft.Text("Year")), - *( - ft.DataColumn(label=ft.Text(metric.name)) - for metric in self.project.sorted_conditions - ), - ], - rows=[ - ft.DataRow( - [ - ft.DataCell(ft.Text(year)), - *( - ft.DataCell( - ft.Text( - current_scenario.get_data(year, metric) - or "None" - ) - ) - for metric in self.project.sorted_conditions - ), - ] - ) - for year in year_range - ], - ), - ] - - def redraw(self): - pass diff --git a/source/package/pathways_app/pathways_app/controls/styled_dropdown.py b/source/package/pathways_app/pathways_app/controls/styled_dropdown.py deleted file mode 100644 index 75215ff..0000000 --- a/source/package/pathways_app/pathways_app/controls/styled_dropdown.py +++ /dev/null @@ -1,62 +0,0 @@ -# pylint: disable=too-many-arguments -import flet as ft - -from .. import theme -from ..utils import index_of_first - - -class StyledDropdown(ft.Dropdown): - def __init__( - self, - value: str, - options: list[ft.dropdown.Option], - option_icons: list[str] | None = None, - width: ft.OptionalNumber = None, - height: ft.OptionalNumber = 28, - text_style: ft.TextStyle | None = None, - on_change=None, - on_blur=None, - ): - super().__init__( - value=value, - text_style=text_style, - expand=False, - options=options, - width=width, - bgcolor=theme.colors.true_white, - content_padding=ft.padding.symmetric(4, 8), - padding=0, - height=height, - border_color=theme.colors.primary_dark, - icon_enabled_color=theme.colors.primary_dark, - on_blur=on_blur, - ) - - def update_icon(): - if option_icons is None: - return - - option_index = index_of_first(options, lambda el: el.key == self.value) - if option_index is not None: - self.prefix = ft.Row( - spacing=5, - controls=[ - ft.Icon( - option_icons[option_index], - color=theme.colors.primary_dark, - expand=False, - ), - ft.Text(self.value, style=text_style), - ], - ) - - def on_value_changed(e): - update_icon() - self.update() - - if on_change is not None: - on_change(e) - - self.prefix = None - update_icon() - self.on_change = on_value_changed diff --git a/source/package/pathways_app/pathways_app/controls/styled_table.py b/source/package/pathways_app/pathways_app/controls/styled_table.py deleted file mode 100644 index 26e4fad..0000000 --- a/source/package/pathways_app/pathways_app/controls/styled_table.py +++ /dev/null @@ -1,66 +0,0 @@ -# pylint: disable=too-many-arguments -import flet as ft - -from .. import theme - - -class StyledTable(ft.DataTable): - def __init__( - self, - columns: list[ft.DataColumn], - rows: list[ft.DataRow], - row_height=36, - sort_column_index: int | None = None, - sort_ascending: bool | None = None, - show_checkboxes=False, - ): - super().__init__( - expand=True, - horizontal_margin=0, - show_checkbox_column=show_checkboxes, - checkbox_horizontal_margin=theme.variables.table_cell_padding.left, - columns=columns, - rows=rows, - bgcolor=theme.colors.true_white, - sort_column_index=sort_column_index, - sort_ascending=sort_ascending, - column_spacing=20, - data_row_min_height=0, - data_row_max_height=row_height, - data_row_color={ - ft.ControlState.HOVERED: theme.colors.off_white, - ft.ControlState.FOCUSED: theme.colors.off_white, - ft.ControlState.SELECTED: theme.colors.primary_white, - ft.ControlState.PRESSED: theme.colors.off_white, - ft.ControlState.DRAGGED: theme.colors.off_white, - ft.ControlState.SCROLLED_UNDER: theme.colors.off_white, - }, - divider_thickness=0, - horizontal_lines=ft.BorderSide(1, theme.colors.primary_lightest), - heading_row_height=30, - heading_row_color=theme.colors.primary_lightest, - heading_text_style=theme.text.table_header, - border=ft.border.all(1, theme.colors.primary_light), - ) - - self.set_columns(columns) - self.set_rows(rows) - - def set_columns(self, columns: list[ft.DataColumn]): - for column in columns: - column.label = ft.Container( - content=column.label, - expand=True, - padding=theme.variables.table_cell_padding, - ) - self.columns = columns - - def set_rows(self, rows: list[ft.DataRow]): - for row in rows: - for cell in row.cells: - cell.content = ft.Container( - content=cell.content, - padding=theme.variables.table_cell_padding, - ) - - self.rows = rows diff --git a/source/package/pathways_app/pathways_app/controls/tabbed_panel.py b/source/package/pathways_app/pathways_app/controls/tabbed_panel.py deleted file mode 100644 index 92b6306..0000000 --- a/source/package/pathways_app/pathways_app/controls/tabbed_panel.py +++ /dev/null @@ -1,88 +0,0 @@ -import flet as ft - -from .. import theme -from .header import SectionHeader - - -class TabbedPanel(ft.Column): - selected_index: int = 0 - tab_buttons: list[ft.TextButton] - content: ft.Container - - def __init__( - self, tabs: list[tuple[SectionHeader, ft.Control]], selected_index: int - ): - def get_tab_bgcolor(index: int): - if self.selected_index == index: - return theme.colors.primary_white - return theme.colors.primary_white - - def get_opacity(index: int): - return 1 if self.selected_index == index else 0.5 - - def on_click(e): - self.selected_index = self.tab_buttons.index(e.control) - - for [index, tab] in enumerate(self.tab_buttons): - tab.content.bgcolor = get_tab_bgcolor(index) - tab.content.opacity = get_opacity(index) - tab.content.update() - - self.content.content = tabs[self.selected_index][1] - self.content.update() - - super().__init__( - expand=True, spacing=0, horizontal_alignment=ft.CrossAxisAlignment.STRETCH - ) - - self.selected_index = selected_index - - self.tab_buttons = [ - ft.TextButton( - expand=True, - content=ft.Container( - expand=True, - content=tab[0], - padding=10, - bgcolor=get_tab_bgcolor(index), - opacity=get_opacity(index), - border_radius=ft.border_radius.only( - top_left=theme.variables.small_radius, - top_right=theme.variables.small_radius, - ), - border=ft.border.only( - left=ft.BorderSide(1, theme.colors.primary_light), - right=ft.BorderSide(1, theme.colors.primary_light), - top=ft.BorderSide(1, theme.colors.primary_light), - ), - ), - style=ft.ButtonStyle(padding=0), - on_click=on_click, - ) - for [index, tab] in enumerate(tabs) - ] - - self.content = ft.Container( - expand=True, - content=tabs[selected_index][1], - margin=0, - padding=theme.variables.panel_padding, - bgcolor=theme.colors.primary_white, - border=ft.border.only( - left=ft.BorderSide(1, theme.colors.primary_light), - right=ft.BorderSide(1, theme.colors.primary_light), - bottom=ft.BorderSide(1, theme.colors.primary_light), - ), - border_radius=ft.border_radius.only( - bottom_left=theme.variables.small_radius, - bottom_right=theme.variables.small_radius, - ), - ) - - self.controls = [ - ft.Row( - controls=self.tab_buttons, - spacing=3, - ), - self.content, - ] diff --git a/source/package/pathways_app/pathways_app/example.py b/source/package/pathways_app/pathways_app/example.py deleted file mode 100644 index 02d362a..0000000 --- a/source/package/pathways_app/pathways_app/example.py +++ /dev/null @@ -1,107 +0,0 @@ -import flet as ft - -from adaptation_pathways.app.model.action import Action, MetricEffect -from adaptation_pathways.app.model.metric import Metric, MetricValue -from adaptation_pathways.app.model.pathway import Pathway -from adaptation_pathways.app.model.pathways_project import PathwaysProject -from adaptation_pathways.app.model.scenario import Scenario - - -metric_sea_level_rise = Metric( - "sea-level-rise", - name="Sea Level Rise", - unit_or_default="cm", - current_value=10, -) - -metric_cost = Metric( - "cost", - name="Cost", - unit_or_default="€", - current_value=0, -) - -metric_habitat_health = Metric( - "habitat", - name="Habitat Health", - unit_or_default="Impact", - current_value=0, -) - -action_root = Action( - "current-situation", - name="Current", - color="#999999", - icon=ft.icons.HOME, - metric_data={ - metric_sea_level_rise.id: MetricEffect(0), - metric_cost.id: MetricEffect(0), - metric_habitat_health.id: MetricEffect(0), - }, -) - -action_sea_wall = Action( - "sea-wall", - name="Sea Wall", - color="#5A81DB", - icon=ft.icons.WATER, - metric_data={ - metric_sea_level_rise.id: MetricEffect(10), - metric_cost.id: MetricEffect(100000), - metric_habitat_health.id: MetricEffect(-2), - }, -) - -action_pump = Action( - "pump", - name="Pump", - color="#44C1E1", - icon=ft.icons.WATER_DROP_SHARP, - metric_data={ - metric_sea_level_rise.id: MetricEffect(5), - metric_cost.id: MetricEffect(50000), - metric_habitat_health.id: MetricEffect(-1), - }, -) - -action_nature_based = Action( - id="nature-based", - name="Nature-Based", - color="#E0C74B", - icon=ft.icons.PARK, - metric_data={ - metric_sea_level_rise.id: MetricEffect(1), - metric_cost.id: MetricEffect(5000), - metric_habitat_health.id: MetricEffect(2), - }, -) - -root_pathway = Pathway(action_root.id) - -project = PathwaysProject( - project_id="test-id", - name="Sea Level Rise Adaptation", - organization="Cork City Council", - start_year=2024, - end_year=2054, - conditions=[metric_sea_level_rise], - criteria=[metric_cost], - scenarios=[ - Scenario( - id="scenario-1", - name="Best Case", - metric_data_over_time={ - 2025: {metric_sea_level_rise.id: MetricValue(13)}, - 2026: {metric_sea_level_rise.id: MetricValue(21)}, - }, - ) - ], - actions=[action_pump, action_sea_wall, action_nature_based], - pathways=[root_pathway], - root_action=action_root, - root_pathway_id=root_pathway.id, -) - -project.create_pathway(action_pump.id, root_pathway.id) -project.create_pathway(action_sea_wall.id, root_pathway.id) -project.create_pathway(action_nature_based.id, root_pathway.id) diff --git a/source/package/pathways_app/pathways_app/utils.py b/source/package/pathways_app/pathways_app/utils.py deleted file mode 100644 index 64b0cea..0000000 --- a/source/package/pathways_app/pathways_app/utils.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import Any, Callable - - -def index_of_first(element_list: list[Any], pred: Callable[[Any], bool]) -> int | None: - for index, value in enumerate(element_list): - if pred(value): - return index - return None diff --git a/source/package/pathways_app/pyproject.toml.in b/source/package/pathways_app/pyproject.toml.in index 603a8d9..b0acdba 100644 --- a/source/package/pathways_app/pyproject.toml.in +++ b/source/package/pathways_app/pyproject.toml.in @@ -14,10 +14,10 @@ packages = false cleanup = false [tool.flet.web] -base_url = "PathwaysGenerator" +base_url = "@AP_BASE_URL@" # renderer = "canvaskit" # use_color_emoji = false -route_url_strategy = "path" +route_url_strategy = "hash" [project] name = "Pathways Generator" @@ -26,6 +26,7 @@ description = "@CMAKE_PROJECT_DESCRIPTION@" authors = "@AP_AUTHORS@" dependencies = [ "adaptation_pathways==@CMAKE_PROJECT_VERSION@", + "jsonpickle", "pyparsing", "flet>=0.25.1" ] diff --git a/source/package/pathways_app/pathways_app/cli/__init__.py b/source/package/pathways_app/src/cli/__init__.py similarity index 100% rename from source/package/pathways_app/pathways_app/cli/__init__.py rename to source/package/pathways_app/src/cli/__init__.py diff --git a/source/package/pathways_app/src/cli/app.py b/source/package/pathways_app/src/cli/app.py new file mode 100644 index 0000000..072abf2 --- /dev/null +++ b/source/package/pathways_app/src/cli/app.py @@ -0,0 +1,80 @@ +import locale + +import flet as ft +from src import theme +from src.controls.menu_bar import MenuBar +from src.controls.pages.editor_page import EditorPage +from src.controls.pages.startup_page import StartupPage +from src.controls.pages.wizard_page import WizardPage +from src.pathways_app import PathwaysApp + + +locale.setlocale(locale.LC_ALL, "") + + +def main(page: ft.Page): + # bitdojo_window could make a custom title bar + # page.window.frameless = True + # page.window.width = 1200 + # page.window.height = 800 + page.theme = theme.theme + page.theme_mode = ft.ThemeMode.LIGHT + + page.window.resizable = True + page.window.alignment = ft.alignment.center + page.window.maximized = True + + page.title = "Pathways Generator" + page.fonts = theme.fonts + page.bgcolor = theme.colors.primary_darker + page.padding = 1 + page.spacing = 0 + + app = PathwaysApp(page) + + menu_bar = MenuBar(app) + page.appbar = menu_bar + + app_container = ft.Container( + expand=True, + padding=theme.variables.panel_spacing, + content=None, + bgcolor=theme.colors.primary_lighter, + border_radius=ft.border_radius.only(bottom_left=8, bottom_right=8), + alignment=ft.alignment.center, + ) + + page.add(app_container) + + def redraw(): + menu_bar.redraw() + app_container.content.redraw() + + def rerender(): + troute = ft.TemplateRoute(page.route) + + if troute.match("/wizard"): + app_container.content = WizardPage(app) + elif troute.match("/project"): + app_container.content = EditorPage(app) + else: + app_container.content = StartupPage(app) + + menu_bar.redraw() + app_container.update() + page.update() + + def render_route(_): + rerender() + + app.on_conditions_changed.append(redraw) + app.on_criteria_changed.append(redraw) + app.on_scenarios_changed.append(redraw) + app.on_actions_changed.append(redraw) + app.on_action_color_changed.append(redraw) + app.on_pathways_changed.append(redraw) + app.on_project_info_changed.append(redraw) + app.on_project_changed.append(rerender) + + page.on_route_change = render_route + page.go("/") diff --git a/source/package/pathways_app/src/config.py b/source/package/pathways_app/src/config.py new file mode 100644 index 0000000..86310de --- /dev/null +++ b/source/package/pathways_app/src/config.py @@ -0,0 +1,4 @@ +class Config: + project_extension = "pwproj" + about_url = "https://pathways.deltares.nl/" + github_url = "https://github.com/Deltares-research/PathwaysGenerator/" diff --git a/source/package/pathways_app/pathways_app/controls/action_icon.py b/source/package/pathways_app/src/controls/action_icon.py similarity index 94% rename from source/package/pathways_app/pathways_app/controls/action_icon.py rename to source/package/pathways_app/src/controls/action_icon.py index 2de0fe9..d203b42 100644 --- a/source/package/pathways_app/pathways_app/controls/action_icon.py +++ b/source/package/pathways_app/src/controls/action_icon.py @@ -1,15 +1,14 @@ import flet as ft +from src import theme from adaptation_pathways.app.model.action import Action -from .. import theme - class ActionIcon(ft.Container): def __init__(self, action: Action, display_tooltip=True, size=36): self.icon = ft.Icon(action.icon, size=(size * 0.5), color=action.color) self.ring = ft.Icon( - ft.icons.CIRCLE_OUTLINED, + ft.Icons.CIRCLE_OUTLINED, size=size, color=action.color, tooltip=( diff --git a/source/package/pathways_app/src/controls/editable_cell.py b/source/package/pathways_app/src/controls/editable_cell.py new file mode 100644 index 0000000..8ae5224 --- /dev/null +++ b/source/package/pathways_app/src/controls/editable_cell.py @@ -0,0 +1,234 @@ +from abc import ABC +from typing import Callable + +import flet as ft +from pyparsing import abstractmethod +from src import theme + +from .input_filters import IntInputFilter +from .styled_table import TableCell + + +class EditableCell(TableCell, ABC): + def __init__( + self, + display_control: ft.Control, + edit_control: ft.Control, + is_editing=False, + is_calculated=False, + can_reset=False, + alignment: ft.Alignment | None = ft.alignment.center_left, + padding: int | ft.Padding | None = theme.variables.table_cell_padding, + sort_value: str | int | float | None = None, + on_finished_editing: Callable[["EditableCell"], None] | None = None, + ): + self.display_content = display_control + self.input_content = edit_control + self.calculated_icon = ft.Container( + ft.Icon( + ft.Icons.CALCULATE, + size=theme.variables.calculated_icon_size, + color=theme.colors.calculated_icon, + ), + expand=True, + alignment=ft.alignment.top_left, + ) + self.reset_button = ft.Container( + ft.Container( + ft.Icon( + ft.Icons.RESTART_ALT, + size=theme.variables.calculated_icon_size, + color=theme.colors.calculated_icon, + tooltip=ft.Tooltip( + "Recalculate", + bgcolor=theme.colors.calculated_icon, + wait_duration=0, + vertical_offset=15, + text_style=theme.text.action_tooltip, + ), + ), + on_click=self.on_reset_to_calculated, + ), + expand=True, + alignment=ft.alignment.top_left, + ) + + self.is_editing = is_editing + self.is_calculated = is_calculated + self.can_reset = can_reset + self.on_finished_editing = on_finished_editing + + self.update_visibility() + + self.cell_content = ft.Container( + expand=True, + content=ft.Stack( + [ + self.display_content, + ft.Stack( + [ + self.input_content, + ], + expand=True, + ), + self.calculated_icon, + self.reset_button, + ], + expand=True, + alignment=alignment, + ), + padding=padding, + bgcolor=theme.colors.calculated_bg if self.is_calculated else None, + on_click=self.set_editing, + ) + + super().__init__(control=self.cell_content, sort_value=sort_value) + + def set_editing(self, _): + self.is_editing = True + self.update_controls() + + def set_not_editing(self, _): + self.is_editing = False + self.update_controls() + + def update_controls(self): + if self.is_editing: + self.update_input() + else: + self.update_display() + + self.update_visibility() + self.control.update() + + if self.is_editing: + self.input_content.focus() + else: + if self.on_finished_editing is not None: + self.on_finished_editing(self) + + def update_visibility(self): + self.display_content.visible = not self.is_editing + self.input_content.visible = self.is_editing + self.calculated_icon.visible = self.is_calculated and not self.is_editing + self.reset_button.visible = self.can_reset and not self.is_editing + + def on_reset_to_calculated(self, _): + pass + + @abstractmethod + def update_display(self): + pass + + @abstractmethod + def update_input(self): + pass + + +class EditableTextCell(EditableCell): + def __init__(self, source: object, value_attribute: str, on_finished_editing=None): + self.source = source + self.value_attribute = value_attribute + + self.display_content = ft.Text(self.value, expand=True) + self.input_content = ft.TextField( + dense=True, + enable_suggestions=False, + value=self.value, + keyboard_type=ft.KeyboardType.TEXT, + bgcolor=theme.colors.true_white, + border_color=theme.colors.primary_medium, + focused_border_color=theme.colors.primary_light, + cursor_color=theme.colors.primary_medium, + text_style=theme.text.textfield, + prefix_style=theme.text.textfield_symbol, + suffix_style=theme.text.textfield_symbol, + expand=True, + content_padding=ft.padding.symmetric(4, 6), + on_blur=self.set_not_editing, + ) + + def on_finished_editing_internal(_): + self.value = self.input_content.value + self.sort_value = self.value + if on_finished_editing is not None: + on_finished_editing(self) + + super().__init__( + self.display_content, + self.input_content, + on_finished_editing=on_finished_editing_internal, + ) + self.sort_value = self.value + + @property + def value(self) -> str: + return getattr(self.source, self.value_attribute) + + @value.setter + def value(self, value: str): + setattr(self.source, self.value_attribute, value) + + def update_input(self): + self.input_content.value = self.display_content.value + + def update_display(self): + self.display_content.value = self.input_content.value + + def on_reset_to_calculated(self, _): + pass + + +class EditableIntCell(EditableCell): + def __init__(self, source: object, value_attribute: str, on_finished_editing=None): + self.source = source + self.value_attribute = value_attribute + + self.display_content = ft.Text(self.value, expand=True) + self.input_content = ft.TextField( + dense=True, + enable_suggestions=False, + value=self.value, + keyboard_type=ft.KeyboardType.NUMBER, + input_filter=IntInputFilter(), + bgcolor=theme.colors.true_white, + border_color=theme.colors.primary_medium, + focused_border_color=theme.colors.primary_light, + cursor_color=theme.colors.primary_medium, + text_style=theme.text.textfield, + prefix_style=theme.text.textfield_symbol, + suffix_style=theme.text.textfield_symbol, + expand=True, + content_padding=ft.padding.symmetric(4, 6), + on_blur=self.set_not_editing, + ) + + def on_finished_editing_internal(_): + self.value = int(self.input_content.value) + self.sort_value = self.value + if on_finished_editing is not None: + on_finished_editing(self) + + super().__init__( + self.display_content, + self.input_content, + on_finished_editing=on_finished_editing_internal, + ) + self.sort_value = self.value + + @property + def value(self) -> int: + return getattr(self.source, self.value_attribute) + + @value.setter + def value(self, value: int): + setattr(self.source, self.value_attribute, value) + + def update_input(self): + self.input_content.value = self.display_content.value + + def update_display(self): + self.display_content.value = self.input_content.value + + def on_reset_to_calculated(self, _): + pass diff --git a/source/package/pathways_app/src/controls/editors/actions_editor.py b/source/package/pathways_app/src/controls/editors/actions_editor.py new file mode 100644 index 0000000..1ee723b --- /dev/null +++ b/source/package/pathways_app/src/controls/editors/actions_editor.py @@ -0,0 +1,182 @@ +# pylint: disable=too-many-arguments,too-many-instance-attributes +import random + +import flet as ft +from src import theme +from src.pathways_app import PathwaysApp + +from adaptation_pathways.app.model.action import Action + +from ..action_icon import ActionIcon +from ..editable_cell import EditableTextCell +from ..metric_effect import MetricEffectCell +from ..metric_value import MetricValueCell +from ..styled_table import StyledTable, TableCell, TableColumn, TableRow + + +class ActionsEditor(ft.Column): + def __init__(self, app: PathwaysApp): + super().__init__( + expand=True, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + spacing=40, + ) + + self.app = app + + self.action_table = StyledTable( + columns=[], + rows=[], + row_height=42, + on_add=self.on_new_action, + on_delete=self.on_delete_actions, + ) + self.update_table() + + self.controls = [ + ft.Column( + expand=True, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + controls=[ + self.action_table, + ], + ), + ] + + def redraw(self): + self.update_table() + self.update() + + def on_name_edited(self, _): + self.app.notify_actions_changed() + + def on_cell_edited(self, cell: MetricValueCell): + self.app.project.update_pathway_values(cell.metric.id) + self.app.notify_actions_changed() + + def on_delete_actions(self, rows: list[TableRow]): + self.app.project.delete_actions(row.row_id for row in rows) + self.app.notify_actions_changed() + + def on_new_action(self): + self.app.project.create_action( + random.choice(theme.action_colors), + random.choice(theme.action_icons), + ) + self.app.notify_actions_changed() + self.update() + + def update_table(self): + columns = [ + TableColumn(label="Icon", width=45, expand=False, sortable=False), + TableColumn( + label="Name", + ), + *( + TableColumn(label=metric.name, key=metric.id) + for metric in self.app.project.all_metrics() + ), + ] + self.action_table.set_columns(columns) + + self.update_rows() + + def create_icon_editor(self, action: Action): + def on_color_picked(color: str): + action.color = color + action_icon.update_action(action) + self.app.notify_action_color_changed() + + def on_icon_picked(icon: str): + action.icon = icon + action_icon.update_action(action) + self.app.notify_action_color_changed() + + def on_editor_closed(_): + self.app.notify_actions_changed() + + def update_items(): + action_button.items = [ + ft.PopupMenuItem( + content=ft.GridView( + spacing=4, + width=200, + runs_count=4, + padding=ft.padding.symmetric(4, 6), + child_aspect_ratio=1.0, + controls=[ + ft.Container( + bgcolor=color, + border=ft.border.all( + 2, + ( + theme.colors.primary_medium + if action.color == color + else theme.colors.primary_lightest + ), + ), + on_click=lambda e, c=color: on_color_picked(c), + width=10, + height=10, + ) + for color in theme.action_colors + ], + ) + ), + ft.PopupMenuItem( + content=ft.GridView( + expand=True, + runs_count=5, + padding=ft.padding.symmetric(12, 6), + controls=[ + ft.Container( + ft.Icon(icon), + on_click=lambda e, i=icon: on_icon_picked(i), + ) + for icon in theme.action_icons + ], + spacing=4, + ) + ), + ] + + action_icon = ActionIcon(action) + + action_button = ft.PopupMenuButton( + action_icon, + items=[], + bgcolor=theme.colors.off_white, + menu_position=ft.PopupMenuPosition.UNDER, + on_cancel=on_editor_closed, + ) + update_items() + return action_button + + def update_rows(self): + rows = [] + + for action in self.app.project.all_actions: + if action.id == self.app.project.root_pathway.action_id: + continue + + metric_cells = [] + for metric in self.app.project.all_metrics(): + effect = action.metric_data[metric.id] + metric_cells.append( + MetricEffectCell( + metric, effect, on_finished_editing=self.on_cell_edited + ) + ) + + rows.append( + TableRow( + row_id=action.id, + cells=[ + TableCell(self.create_icon_editor(action)), + EditableTextCell(action, "name", self.on_name_edited), + *metric_cells, + ], + ) + ) + + self.action_table.set_rows(rows) diff --git a/source/package/pathways_app/src/controls/editors/conditions_editor.py b/source/package/pathways_app/src/controls/editors/conditions_editor.py new file mode 100644 index 0000000..f936fa7 --- /dev/null +++ b/source/package/pathways_app/src/controls/editors/conditions_editor.py @@ -0,0 +1,71 @@ +# from typing import Callable + +import flet as ft +from src.pathways_app import PathwaysApp + +from adaptation_pathways.app.model.metric import Metric + +from ..editable_cell import EditableTextCell +from ..styled_table import StyledTable, TableColumn, TableRow +from ..unit_cell import MetricUnitCell + + +class ConditionsEditor(ft.Column): + def __init__( + self, app: PathwaysApp, pre_operation_content: ft.Control | None = None + ): + self.app = app + + self.conditions_table = StyledTable( + columns=[ + TableColumn(label="Name"), + TableColumn(label="Unit"), + ], + rows=[], + pre_operation_content=pre_operation_content, + on_add=self.on_new_condition, + on_delete=self.on_delete_conditions, + ) + + self.update_metrics() + + super().__init__( + [self.conditions_table], + expand=True, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + ) + + def redraw(self): + self.update_metrics() + self.update() + + def on_metric_updated(self, _): + self.app.notify_conditions_changed() + + def on_new_condition(self): + metric = self.app.project.create_condition() + self.app.project.update_pathway_values(metric.id) + self.app.notify_conditions_changed() + + def on_delete_conditions(self, rows: list[TableRow]): + for row in rows: + self.app.project.delete_condition(row.row_id) + self.app.notify_conditions_changed() + + def get_metric_row( + self, + metric: Metric, + ) -> TableRow: + row = TableRow( + row_id=metric.id, + cells=[ + EditableTextCell(metric, "name", self.on_metric_updated), + MetricUnitCell(metric, self.on_metric_updated), + ], + ) + return row + + def update_metrics(self): + self.conditions_table.set_rows( + self.get_metric_row(metric) for metric in self.app.project.all_conditions + ) diff --git a/source/package/pathways_app/src/controls/editors/criteria_editor.py b/source/package/pathways_app/src/controls/editors/criteria_editor.py new file mode 100644 index 0000000..ccee633 --- /dev/null +++ b/source/package/pathways_app/src/controls/editors/criteria_editor.py @@ -0,0 +1,78 @@ +# from typing import Callable + +import flet as ft +from src.pathways_app import PathwaysApp + +from adaptation_pathways.app.model.metric import Metric + +from ..editable_cell import EditableTextCell +from ..styled_table import StyledTable, TableColumn, TableRow +from ..unit_cell import MetricUnitCell + + +class CriteriaEditor(ft.Column): + def __init__( + self, app: PathwaysApp, pre_operation_content: ft.Control | None = None + ): + super().__init__() + + self.app = app + self.expand = True + self.horizontal_alignment = ft.CrossAxisAlignment.STRETCH + + self.criteria_table = StyledTable( + columns=[ + TableColumn(label="Name"), + TableColumn(label="Unit"), + ], + rows=[], + pre_operation_content=pre_operation_content, + on_add=self.on_new_criteria, + on_delete=self.on_delete_criteria, + ) + + self.update_metrics() + + self.controls = [ + self.criteria_table, + ] + + def redraw(self): + self.update_metrics() + self.update() + + def on_metric_updated(self, _): + self.app.notify_conditions_changed() + + def on_new_criteria(self): + metric = self.app.project.create_criteria() + self.app.project.update_pathway_values(metric.id) + self.app.notify_criteria_changed() + + def on_delete_criteria(self, rows: list[TableRow]): + for row in rows: + self.app.project.delete_criteria(row.row_id) + self.app.notify_criteria_changed() + + def get_metric_row( + self, + metric: Metric, + ) -> TableRow: + row = TableRow( + row_id=metric.id, + cells=[ + EditableTextCell(metric, "name", self.on_metric_updated), + MetricUnitCell(metric, self.on_metric_updated), + ], + ) + return row + + def update_metrics(self): + self.criteria_table.set_rows( + [ + self.get_metric_row( + metric, + ) + for metric in self.app.project.all_criteria + ] + ) diff --git a/source/package/pathways_app/src/controls/editors/graph_editor.py b/source/package/pathways_app/src/controls/editors/graph_editor.py new file mode 100644 index 0000000..33e156f --- /dev/null +++ b/source/package/pathways_app/src/controls/editors/graph_editor.py @@ -0,0 +1,276 @@ +import traceback +from typing import Callable + +import flet as ft +import matplotlib.pyplot +from flet.matplotlib_chart import MatplotlibChart +from src import theme +from src.pathways_app import PathwaysApp + +from adaptation_pathways.app.service.plotting_service import PlottingService + +from ..header import SmallHeader +from ..styled_button import StyledButton +from ..styled_dropdown import StyledDropdown + + +class GraphHeader(ft.Row): + def __init__( + self, + on_expand: Callable[[], None] | None = None, + on_sidebar: Callable[[], None] | None = None, + ): + self.on_expand = on_expand + self.on_sidebar = on_sidebar + + self.expand_icon = ft.Icon( + ft.Icons.OPEN_IN_FULL, + color=theme.colors.primary_dark, + size=theme.variables.icon_button_size, + ) + self.sidebar_icon = ft.Icon( + ft.Icons.VIEW_SIDEBAR, + color=theme.colors.primary_dark, + size=theme.variables.icon_button_size, + ) + + super().__init__( + expand=False, + controls=[ + ft.Container(self.sidebar_icon, on_click=self.on_sidebar_clicked), + ft.Container(expand=True), + ft.Container(self.expand_icon, on_click=self.on_expand_clicked), + ], + ) + self.set_sidebar_open(True) + self.set_expanded(False) + + def set_expanded(self, expanded: bool): + self.expand_icon.name = ( + theme.icons.minimize if expanded else theme.icons.maximize + ) + + def on_expand_clicked(self, _): + if self.on_expand is None: + return + self.on_expand() + + def set_sidebar_open(self, is_open: bool): + self.sidebar_icon.name = ( + theme.icons.sidebar_open if is_open else theme.icons.sidebar_closed + ) + + def on_sidebar_clicked(self, _): + if self.on_sidebar is None: + return + self.on_sidebar() + + +class GraphEditor(ft.Row): + def __init__(self, app: PathwaysApp): + super().__init__(expand=False, spacing=0) + + self.app = app + + self.header = GraphHeader(on_sidebar=self.on_sidebar_toggle) + + self.graph_container = ft.Container( + expand=True, bgcolor=theme.colors.true_white + ) + + self.metric_dropdown = StyledDropdown( + value="none", + options=[], + width=200, + on_change=self.on_graph_metric_changed, + ) + + self.graph_options = ft.Column([], expand=False, spacing=3) + self.time_metric_option = StyledDropdown( + self.app.project.graph_metric_id or "none", + options=[], + on_change=self.on_time_metric_changed, + ) + + self.graph_scenario_option = StyledDropdown( + self.app.project.graph_scenario_id or "none", + options=[], + on_change=self.on_graph_scenario_changed, + ) + + self.update_parameters() + + self.sidebar = ft.Container( + expand=False, + padding=theme.variables.panel_padding, + content=ft.Column( + expand=False, + width=200, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + controls=[ + StyledDropdown( + "Metro Map", + options=[ + ft.dropdown.Option("Metro Map"), + ft.dropdown.Option("Bar Chart"), + ], + option_icons=[ft.Icons.ROUTE_OUTLINED, ft.Icons.BAR_CHART], + height=36, + text_style=theme.text.dropdown_large, + ), + self.graph_options, + ft.Container(expand=True), + ft.Row( + [ + StyledButton("Export", icon=ft.Icons.SAVE_SHARP), + ], + alignment=ft.MainAxisAlignment.END, + ), + ], + ), + ) + + self.controls = [ + self.sidebar, + ft.Container( + expand=True, + bgcolor=theme.colors.true_white, + border_radius=ft.border_radius.only( + top_right=theme.variables.small_radius, + bottom_right=theme.variables.small_radius, + ), + padding=ft.padding.only(bottom=10), + content=ft.Stack( + expand=True, + controls=[ + ft.Column( + [ + ft.Container(height=10), + self.graph_container, + self.metric_dropdown, + ], + expand=True, + alignment=ft.MainAxisAlignment.END, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + ), + ft.Container( + content=self.header, + padding=theme.variables.panel_padding, + alignment=ft.alignment.top_center, + height=36, + ), + ], + alignment=ft.alignment.top_center, + ), + ), + ] + + self.update_graph() + + def on_sidebar_toggle(self): + self.sidebar.visible = not self.sidebar.visible + self.header.set_sidebar_open(self.sidebar.visible) + self.update() + + def on_graph_metric_changed(self, _): + self.app.project.graph_is_time = self.metric_dropdown.value == "time" + if not self.app.project.graph_is_time: + self.app.project.graph_metric_id = self.metric_dropdown.value + self.redraw() + + def on_time_metric_changed(self, _): + self.app.project.graph_metric_id = self.time_metric_option.value + self.redraw() + + def on_graph_scenario_changed(self, _): + self.app.project.graph_scenario_id = self.graph_scenario_option.value + self.redraw() + + def redraw(self): + self.update_parameters() + self.update_graph() + self.update() + + def update_parameters(self): + if self.app.project.graph_is_time: + self.time_metric_option.set_options( + [ + ft.dropdown.Option( + key="none", text="- Select a Condition -", disabled=True + ), + *( + ft.dropdown.Option( + key=metric.id, + text=f"{metric.name} ({metric.unit.symbol})", + ) + for metric in self.app.project.all_conditions + ), + ] + ) + + self.graph_scenario_option.set_options( + [ + ft.dropdown.Option( + key="none", text="- Select a Scenario -", disabled=True + ), + *( + ft.dropdown.Option( + key=scenario.id, + text=f"{scenario.name}", + ) + for scenario in self.app.project.all_scenarios + ), + ] + ) + + self.graph_options.controls = [ + ft.Container(height=0), + SmallHeader("Condition"), + self.time_metric_option, + ft.Container(height=15), + SmallHeader("Scenario"), + self.graph_scenario_option, + ] + else: + self.graph_options.controls = [] + + self.metric_dropdown.value = ( + "time" + if self.app.project.graph_is_time + else self.app.project.graph_metric_id or "none" + ) + self.metric_dropdown.set_options( + [ + ft.dropdown.Option( + key="none", + text="- Choose X-Axis -", + disabled=True, + ), + ft.dropdown.Option( + key="time", + text="Time", + disabled=len(self.app.project.scenarios_by_id) == 0, + ), + *( + ft.dropdown.Option( + key=metric.id, text=f"{metric.name} ({metric.unit.symbol})" + ) + for metric in self.app.project.all_conditions + ), + ] + ) + + def update_graph(self): + if self.app.project.graph_metric is None: + self.graph_container.visible = False + return + + try: + figure, _ = PlottingService.draw_metro_map(self.app.project) + self.graph_container.visible = True + self.graph_container.content = MatplotlibChart(figure) + matplotlib.pyplot.close(figure) + except Exception: + print("Error when attempting to draw graph") + print(traceback.format_exc()) + self.graph_container.visible = False diff --git a/source/package/pathways_app/src/controls/editors/metrics_editor.py b/source/package/pathways_app/src/controls/editors/metrics_editor.py new file mode 100644 index 0000000..1fc7487 --- /dev/null +++ b/source/package/pathways_app/src/controls/editors/metrics_editor.py @@ -0,0 +1,65 @@ +# from typing import Callable + +import flet as ft +from src.pathways_app import PathwaysApp + +from ..editors.conditions_editor import ConditionsEditor +from ..editors.criteria_editor import CriteriaEditor +from ..header import SmallHeader + + +class MetricsEditor(ft.Column): + def __init__(self, app: PathwaysApp): + super().__init__() + + self.app = app + self.expand = True + self.horizontal_alignment = ft.CrossAxisAlignment.STRETCH + self.spacing = 40 + + self.conditions_editor = ConditionsEditor( + app, + pre_operation_content=ft.Row( + [ + SmallHeader("Conditions"), + ft.Container(expand=True), + ], + expand=True, + ), + ) + + self.criteria_editor = CriteriaEditor( + app, + pre_operation_content=ft.Row( + [ + SmallHeader("Criteria"), + ft.Container(expand=True), + ], + expand=True, + ), + ) + + self.controls = [ + ft.Column( + expand=1, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + controls=[ + self.conditions_editor, + ], + ), + ft.Column( + expand=3, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + controls=[ + self.criteria_editor, + ], + ), + ] + + def redraw(self): + self.update_metrics() + self.update() + + def update_metrics(self): + self.conditions_editor.update_metrics() + self.criteria_editor.update_metrics() diff --git a/source/package/pathways_app/src/controls/editors/pathways_editor.py b/source/package/pathways_app/src/controls/editors/pathways_editor.py new file mode 100644 index 0000000..931841c --- /dev/null +++ b/source/package/pathways_app/src/controls/editors/pathways_editor.py @@ -0,0 +1,163 @@ +import flet as ft +from src import theme +from src.pathways_app import PathwaysApp + +from adaptation_pathways.app.model.pathway import Pathway + +from ..action_icon import ActionIcon +from ..metric_value import MetricValueCell +from ..styled_table import StyledTable, TableCell, TableColumn, TableRow + + +class PathwaysEditor(ft.Container): + def __init__(self, app: PathwaysApp): + self.app = app + + self.rows_by_pathway: dict[Pathway, ft.DataRow] = {} + + self.pathway_table = StyledTable( + columns=[], rows=[], show_checkboxes=True, on_delete=self.on_delete_pathways + ) + self.update_table() + + super().__init__( + expand=True, + content=ft.Column( + expand=True, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + controls=[ + self.pathway_table, + ], + ), + ) + + def redraw(self): + self.update_table() + self.update() + + def on_delete_pathways(self, rows: list[TableRow]): + self.app.project.delete_pathways(row.row_id for row in rows) + self.app.notify_pathways_changed() + + def update_table(self): + self.pathway_table.set_columns( + [ + TableColumn( + label="Pathway", + expand=2, + ), + *( + TableColumn( + label=metric.name, + key=metric.id, + alignment=ft.alignment.center_right, + ) + for metric in self.app.project.all_metrics() + ), + ] + ) + + rows = [] + + self.rows_by_pathway = {} + for pathway in self.app.project.all_pathways: + ancestors = self.app.project.get_ancestors_and_self(pathway) + path = [*reversed([*ancestors])] + row = self.get_pathway_row(pathway, path) + self.rows_by_pathway[pathway] = row + rows.append(row) + + self.pathway_table.set_rows(rows) + return rows + + def on_metric_value_edited(self, cell: MetricValueCell): + self.app.project.update_pathway_values(cell.metric.id) + self.app.notify_pathways_changed() + + def get_pathway_row(self, pathway: Pathway, ancestors: list[Pathway]): + children = [*self.app.project.get_children(pathway.id)] + pathway_action = self.app.project.get_action(pathway.action_id) + + unused_action_ids = [ + action.id + for action in self.app.project.all_actions + if not any(ancestor.action_id == action.id for ancestor in ancestors) + and not any(child.action_id == action.id for child in children) + ] + + row_controls = [ + ActionIcon(self.app.project.get_action(ancestor.action_id), size=26) + for ancestor in ancestors + ] + + if pathway.parent_id is None: + row_controls.append(ft.Text(" Current ", color=pathway_action.color)) + + if len(unused_action_ids) > 0: + row_controls.append( + ft.PopupMenuButton( + ft.Icon( + ft.Icons.ADD_CIRCLE_OUTLINE, + size=20, + color=theme.colors.primary_lightest, + ), + items=[ + ft.PopupMenuItem( + content=ft.Row( + [ + ActionIcon( + action, + display_tooltip=False, + ), + ft.Text( + action.name, + style=theme.text.normal, + ), + ] + ), + on_click=lambda e, action_id=action_id: self.extend_pathway( + pathway, action_id + ), + ) + for action_id in unused_action_ids + for action in [self.app.project.get_action(action_id)] + ], + tooltip=ft.Tooltip( + "Add", + bgcolor=theme.colors.primary_white, + ), + bgcolor=theme.colors.off_white, + menu_position=ft.PopupMenuPosition.UNDER, + ), + ) + + row = TableRow( + row_id=pathway.id, + cells=[ + TableCell( + ft.Container( + expand=True, + content=ft.Row( + spacing=0, + controls=row_controls, + ), + ), + sort_value=pathway.id, + ), + *( + MetricValueCell( + metric, + pathway.metric_data[metric.id], + on_finished_editing=self.on_metric_value_edited, + ) + for metric in self.app.project.all_metrics() + ), + ], + can_be_deleted=pathway.id != self.app.project.root_pathway_id, + ) + + return row + + def extend_pathway(self, pathway: Pathway, action_id: str): + self.app.project.create_pathway(action_id, pathway.id) + self.app.notify_pathways_changed() diff --git a/source/package/pathways_app/src/controls/editors/project_info_editor.py b/source/package/pathways_app/src/controls/editors/project_info_editor.py new file mode 100644 index 0000000..6ce3d94 --- /dev/null +++ b/source/package/pathways_app/src/controls/editors/project_info_editor.py @@ -0,0 +1,66 @@ +import flet as ft +from src import theme +from src.pathways_app import PathwaysApp + +from ..header import SmallHeader + + +class ProjectInfoEditor(ft.Container): + def __init__(self, app: PathwaysApp): + self.app = app + + self.project_name_input = ft.TextField( + dense=True, + enable_suggestions=False, + value=self.app.project.name, + keyboard_type=ft.KeyboardType.TEXT, + bgcolor=theme.colors.true_white, + border_color=theme.colors.primary_medium, + focused_border_color=theme.colors.primary_light, + cursor_color=theme.colors.primary_medium, + text_style=theme.text.textfield, + expand=False, + content_padding=ft.padding.symmetric(12, 8), + on_blur=self.on_name_edited, + width=400, + ) + + self.project_org_input = ft.TextField( + dense=True, + enable_suggestions=False, + value=self.app.project.organization, + keyboard_type=ft.KeyboardType.TEXT, + bgcolor=theme.colors.true_white, + border_color=theme.colors.primary_medium, + focused_border_color=theme.colors.primary_light, + cursor_color=theme.colors.primary_medium, + text_style=theme.text.textfield, + expand=False, + content_padding=ft.padding.symmetric(12, 8), + on_blur=self.on_organization_edited, + width=400, + ) + + super().__init__( + ft.Column( + controls=[ + SmallHeader("Project Name"), + self.project_name_input, + SmallHeader("Organization"), + self.project_org_input, + ], + expand=False, + ), + expand=False, + ) + + def redraw(self): + pass + + def on_name_edited(self, _): + self.app.project.name = self.project_name_input.value + self.app.notify_project_info_changed() + + def on_organization_edited(self, _): + self.app.project.organization = self.project_org_input.value + self.app.notify_project_info_changed() diff --git a/source/package/pathways_app/src/controls/editors/scenarios_editor.py b/source/package/pathways_app/src/controls/editors/scenarios_editor.py new file mode 100644 index 0000000..d11e1f4 --- /dev/null +++ b/source/package/pathways_app/src/controls/editors/scenarios_editor.py @@ -0,0 +1,211 @@ +import datetime +from functools import partial + +import flet as ft +from src.pathways_app import PathwaysApp +from src.utils import find_index + +from adaptation_pathways.app.model.metric import Metric +from adaptation_pathways.app.model.scenario import YearDataPoint + +from ..editable_cell import EditableIntCell, EditableTextCell +from ..header import SmallHeader +from ..metric_value import MetricValueCell +from ..styled_dropdown import StyledDropdown +from ..styled_table import StyledTable, TableColumn, TableRow + + +class ScenariosEditor(ft.Column): + def __init__(self, app: PathwaysApp): + super().__init__( + expand=True, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + ) + + self.app = app + + self.scenario_table = StyledTable( + columns=[TableColumn("Name", key="name")], + rows=[], + expand=1, + on_add=self.on_add_scenario, + on_delete=self.on_delete_scenarios, + on_copy=self.on_copy_scenarios, + ) + self.update_scenario_table() + + self.no_scenario_option = ft.dropdown.Option( + key="none", text="- Choose a Scenario -", disabled=True + ) + self.scenario_dropdown = StyledDropdown( + value=self.app.project.values_scenario_id or "none", + options=[], + on_change=self.on_scenario_changed, + ) + + self.scenario_values_table = StyledTable( + columns=[], + rows=[], + expand=3, + on_add=self.on_add_year, + add_label="Add Year", + on_delete=self.on_delete_years, + pre_operation_content=ft.Row( + [self.scenario_dropdown, ft.Container(expand=True)], expand=True + ), + ) + + self.controls = [ + self.scenario_table, + ft.Container(height=20), + SmallHeader("Scenario Data"), + self.scenario_values_table, + ] + + self.update_dropdown() + self.update_values_table() + + def update_dropdown(self): + self.scenario_dropdown.set_options( + [ + self.no_scenario_option, + *( + ft.dropdown.Option(key=scenario.id, text=scenario.name) + for scenario in self.app.project.all_scenarios + ), + ] + ) + + def update_scenario_table(self): + self.scenario_table.set_rows( + [ + TableRow( + scenario.id, + [EditableTextCell(scenario, "name", self.on_scenario_name_edited)], + ) + for scenario in self.app.project.all_scenarios + ] + ) + + def on_add_scenario(self): + self.app.project.create_scenario("New Scenario") + self.app.notify_scenarios_changed() + + def on_copy_scenarios(self, rows: list[TableRow]): + for row in rows: + self.app.project.copy_scenario(row.row_id) + + self.app.notify_scenarios_changed() + + def on_scenario_name_edited(self): + self.app.notify_scenarios_changed() + + def on_delete_scenarios(self, rows: list[TableRow]): + self.app.project.delete_scenarios(row.row_id for row in rows) + self.app.notify_scenarios_changed() + + def update_values_table(self): + self.update_values_headers() + self.update_values_rows() + + def update_values_headers(self): + self.scenario_values_table.set_columns( + [ + TableColumn(label="Year", sortable=False), + *( + TableColumn(label=metric.name, sortable=False) + for metric in self.app.project.all_conditions + ), + ] + ) + + def update_values_rows(self): + if self.app.project.values_scenario is None: + self.scenario_values_table.set_rows([]) + return + + self.scenario_values_table.set_rows( + [ + self._get_year_row(point) + for point in self.app.project.values_scenario.yearly_data + ] + ) + + def _get_year_row(self, point: YearDataPoint): + return TableRow( + row_id=str(point.year), + cells=[ + EditableIntCell(point, "year", self.on_year_edited), + *( + self._get_metric_cell(metric, point) + for metric in self.app.project.all_conditions + ), + ], + ) + + def _get_metric_cell(self, metric: Metric, point: YearDataPoint): + return MetricValueCell( + metric, + point.get_or_add_data(metric.id), + on_finished_editing=self.on_metric_value_edited, + ) + + def on_year_edited(self, _): + for metric in self.app.project.all_conditions: + self.app.project.values_scenario.recalculate_values(metric.id) + + self.app.project.values_scenario.sort_yearly_data() + self.app.notify_scenarios_changed() + + def on_metric_value_edited(self, cell: MetricValueCell): + self.app.project.values_scenario.recalculate_values(cell.metric.id) + self.app.notify_scenarios_changed() + + def on_scenario_changed(self, _): + self.app.project.values_scenario_id = ( + self.scenario_dropdown.value + if self.scenario_dropdown.value in self.app.project.scenarios_by_id + else None + ) + self.redraw() + + def on_add_year(self): + if self.app.project.values_scenario is None: + return + + scenario = self.app.project.values_scenario + + year = datetime.datetime.now().year + year_count = len(scenario.yearly_data) + + if year_count > 0: + year = scenario.yearly_data[year_count - 1].year + 1 + + scenario.get_or_add_year(year) + for metric in self.app.project.all_conditions: + scenario.recalculate_values(metric.id) + + self.app.notify_scenarios_changed() + + def on_delete_years(self, rows: list[TableRow]): + def is_year(data: YearDataPoint, year: int): + return data.year == year + + for row in rows: + row_year = int(row.row_id) + + data_index = find_index( + self.app.project.values_scenario.yearly_data, + partial(is_year, year=row_year), + ) + if data_index is None: + continue + self.app.project.values_scenario.yearly_data.pop(data_index) + + self.app.notify_scenarios_changed() + + def redraw(self): + self.update_scenario_table() + self.update_dropdown() + self.update_values_table() + self.update() diff --git a/source/package/pathways_app/pathways_app/controls/header.py b/source/package/pathways_app/src/controls/header.py similarity index 62% rename from source/package/pathways_app/pathways_app/controls/header.py rename to source/package/pathways_app/src/controls/header.py index 87746bd..84dbda1 100644 --- a/source/package/pathways_app/pathways_app/controls/header.py +++ b/source/package/pathways_app/src/controls/header.py @@ -1,14 +1,13 @@ # pylint: disable=too-many-arguments import flet as ft - -from .. import theme +from src import theme class SectionHeader(ft.Container): def __init__( self, - icon=None, - text="", + icon: str | None = None, + text: str | None = None, size=16, expand=False, color=theme.colors.primary_dark, @@ -35,20 +34,21 @@ def __init__( weight=ft.FontWeight.W_600, ) - if icon is not None: - self.content = ft.Row( - expand=expand, - controls=[ - ft.Icon(icon, size=size, color=color), - ft.Text(text, style=header_text_style), - ], - spacing=8, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ) - else: - self.content = ft.Row( - expand=True, controls=[ft.Text(text, style=header_text_style)] - ) + self.icon = ft.Icon(icon, size=size, color=color) + self.icon.visible = icon is not None + + self.text = ft.Text(text, style=header_text_style) + self.text.visible = text is not None + + self.content = ft.Row( + expand=expand, + controls=[ + self.icon, + self.text, + ], + spacing=8, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) class SmallHeader(ft.Text): diff --git a/source/package/pathways_app/src/controls/input_filters.py b/source/package/pathways_app/src/controls/input_filters.py new file mode 100644 index 0000000..57f2e7d --- /dev/null +++ b/source/package/pathways_app/src/controls/input_filters.py @@ -0,0 +1,13 @@ +import flet as ft + + +class FloatInputFilter(ft.InputFilter): + def __init__(self): + super().__init__( + regex_string=r"^$|^[-+]?\d*(\.\d*)?$", allow=True, replacement_string="" + ) + + +class IntInputFilter(ft.InputFilter): + def __init__(self): + super().__init__(regex_string=r"^-?\d*$", allow=True, replacement_string="") diff --git a/source/package/pathways_app/src/controls/menu_bar.py b/source/package/pathways_app/src/controls/menu_bar.py new file mode 100644 index 0000000..2894b26 --- /dev/null +++ b/source/package/pathways_app/src/controls/menu_bar.py @@ -0,0 +1,148 @@ +import flet as ft +from src import theme +from src.config import Config +from src.pathways_app import PathwaysApp + + +class MenuBar(ft.Container): + def __init__(self, app: PathwaysApp): + self.app = app + + self.project_name = ft.Text( + app.project.name, + color=theme.colors.true_white, + font_family=theme.font_family_semibold, + size=14, + ) + self.project_org = ft.Text( + app.project.organization, + text_align=ft.TextAlign.CENTER, + color=theme.colors.true_white, + size=10, + ) + + self.update_project_info() + + super().__init__( + content=ft.Stack( + [ + ft.Row( + [ + ft.Image(theme.icon, height=36, width=36), + ft.Text("PATHWAYS\nGENERATOR", style=theme.text.logo), + ft.Container(width=15), + ft.MenuBar( + style=theme.buttons.menu_bar, + controls=[ + ft.SubmenuButton( + ft.Text( + "Project", + style=theme.text.menu_button, + no_wrap=True, + ), + controls=[ + ft.MenuItemButton( + content=ft.Text("New"), + on_click=self.on_new_project, + ), + ft.MenuItemButton( + content=ft.Text("Open..."), + on_click=self.on_open_project, + ), + ft.MenuItemButton( + content=ft.Text("Save As..."), + on_click=self.on_save_project, + ), + ], + style=theme.buttons.menu_bar_button, + ), + ft.Container(width=10), + ft.SubmenuButton( + ft.Text( + "Help", + style=theme.text.menu_button, + no_wrap=True, + ), + controls=[ + ft.MenuItemButton( + content=ft.Text("About Pathways"), + on_click=lambda e: app.open_link( + Config.about_url + ), + ), + ft.MenuItemButton( + content=ft.Text("GitHub Repository"), + on_click=lambda e: app.open_link( + Config.github_url + ), + ), + ft.Container( + content=ft.Text( + "version 1.0.0", + color=theme.colors.primary_light, + ), + padding=ft.padding.symmetric(5, 10), + ), + ], + style=theme.buttons.menu_bar_button, + menu_style=theme.buttons.submenu, + ), + ], + ), + ], + expand=True, + ), + ft.Row( + [ + ft.Container( + bgcolor=theme.colors.primary_medium, + border_radius=theme.variables.small_radius, + alignment=ft.alignment.center, + padding=ft.padding.symmetric(0, 15), + width=300, + content=ft.Stack( + [ + ft.Column( + controls=[ + self.project_name, + self.project_org, + ], + spacing=0, + alignment=ft.MainAxisAlignment.CENTER, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + ) + ] + ), + ) + ], + alignment=ft.MainAxisAlignment.CENTER, + vertical_alignment=ft.MainAxisAlignment.CENTER, + ), + ] + ), + height=50, + padding=ft.padding.symmetric(4, 5), + margin=0, + bgcolor=theme.colors.primary_dark, + border_radius=ft.border_radius.only(top_left=0, top_right=0), + border=ft.border.only( + bottom=ft.border.BorderSide(1, theme.colors.primary_darker) + ), + ) + + def update_project_info(self): + self.project_name.value = self.app.project.name + self.project_org.value = self.app.project.organization.upper() + + def redraw(self): + self.update_project_info() + self.update() + + def on_new_project(self, _): + self.app.new_project() + + def on_open_project(self, _): + self.app.open_project() + + def on_save_project(self, _): + self.app.save_project() diff --git a/source/package/pathways_app/pathways_app/controls/metric_effect.py b/source/package/pathways_app/src/controls/metric_effect.py similarity index 97% rename from source/package/pathways_app/pathways_app/controls/metric_effect.py rename to source/package/pathways_app/src/controls/metric_effect.py index 33ff121..55a8a4b 100644 --- a/source/package/pathways_app/pathways_app/controls/metric_effect.py +++ b/source/package/pathways_app/src/controls/metric_effect.py @@ -1,11 +1,11 @@ from typing import Callable import flet as ft +from src import theme from adaptation_pathways.app.model.action import MetricEffect from adaptation_pathways.app.model.metric import Metric, MetricOperation -from .. import theme from .editable_cell import EditableCell from .metric_value import FloatInputFilter from .styled_dropdown import StyledDropdown @@ -32,7 +32,7 @@ def __init__( def on_input_blurred(_): if not self.input_focused and not self.dropdown_focused: - self.toggle_editing(_) + self.set_not_editing(_) self.operation_dropdown = StyledDropdown( "", @@ -115,3 +115,6 @@ def update_input(self): self.input_content.suffix_text = None self.value_input.value = self.effect.value + + def on_reset_to_calculated(self, _): + pass diff --git a/source/package/pathways_app/pathways_app/controls/metric_value.py b/source/package/pathways_app/src/controls/metric_value.py similarity index 64% rename from source/package/pathways_app/pathways_app/controls/metric_value.py rename to source/package/pathways_app/src/controls/metric_value.py index 6e88822..bded0bf 100644 --- a/source/package/pathways_app/pathways_app/controls/metric_value.py +++ b/source/package/pathways_app/src/controls/metric_value.py @@ -1,33 +1,22 @@ import flet as ft +from src import theme -from adaptation_pathways.app.model.metric import Metric, MetricValue +from adaptation_pathways.app.model.metric import Metric, MetricValue, MetricValueState -from .. import theme from .editable_cell import EditableCell - - -class FloatInputFilter(ft.InputFilter): - def __init__(self): - super().__init__( - regex_string=r"^$|^[-+]?\d*(\.\d*)?$", allow=True, replacement_string="" - ) +from .input_filters import FloatInputFilter class MetricValueCell(EditableCell): def __init__(self, metric: Metric, value: MetricValue, on_finished_editing=None): self.metric = metric self.value = value + self.sort_value = value.value + self.finished_editing_callback = on_finished_editing self.display_content = ft.Text("") self.update_display() - def on_edited(_): - self.value.value = float(self.input_content.value) - self.value.is_estimate = False - self.set_calculated(False) - if on_finished_editing is not None: - on_finished_editing(self) - self.input_content = ft.TextField( dense=True, enable_suggestions=False, @@ -44,7 +33,7 @@ def on_edited(_): text_align=ft.TextAlign.RIGHT, expand=True, content_padding=ft.padding.symmetric(4, 6), - on_blur=self.toggle_editing, + on_blur=self.set_not_editing, ) self.update_input() @@ -52,9 +41,28 @@ def on_edited(_): self.display_content, self.input_content, is_calculated=value.is_estimate, - on_finished_editing=on_edited, + can_reset=value.state == MetricValueState.OVERRIDE, + on_finished_editing=self.on_edited, + alignment=ft.alignment.center_right, + sort_value=self.value.value, ) + def on_edited(self, _): + new_value = float(self.input_content.value) + + if new_value != self.value.value and self.value.is_estimate: + self.value.state = MetricValueState.OVERRIDE + + self.value.value = new_value + self.sort_value = self.value.value + + if self.finished_editing_callback is not None: + self.finished_editing_callback(self) + + def on_reset_to_calculated(self, _): + self.value.state = MetricValueState.ESTIMATE + self.update_controls() + def update_display(self): self.display_content.value = self.metric.unit.format(self.value.value) diff --git a/source/package/pathways_app/src/controls/pages/editor_page.py b/source/package/pathways_app/src/controls/pages/editor_page.py new file mode 100644 index 0000000..163a4f9 --- /dev/null +++ b/source/package/pathways_app/src/controls/pages/editor_page.py @@ -0,0 +1,173 @@ +import flet as ft +from src import theme +from src.pathways_app import PathwaysApp + +from ..editors.actions_editor import ActionsEditor +from ..editors.graph_editor import GraphEditor +from ..editors.metrics_editor import MetricsEditor +from ..editors.pathways_editor import PathwaysEditor +from ..editors.project_info_editor import ProjectInfoEditor +from ..editors.scenarios_editor import ScenariosEditor +from ..panel import Panel +from ..panel_header import PanelHeader +from ..tabbed_panel import TabbedPanel + + +class EditorPage(ft.Row): + def __init__(self, app: PathwaysApp): + self.app = app + self.expanded_editor: ft.Control | None = None + + self.metrics_editor = MetricsEditor(app) + self.metrics_header = PanelHeader("Metrics", theme.icons.metrics) + self.metrics_tab = ( + self.get_tab_button(theme.icons.metrics, "Metrics"), + ft.Column([self.metrics_header, self.metrics_editor]), + ) + + self.actions_editor = ActionsEditor(app) + self.actions_header = PanelHeader(title="Actions", icon=theme.icons.actions) + self.actions_tab = ( + self.get_tab_button(theme.icons.actions, "Actions"), + ft.Column([self.actions_header, self.actions_editor], expand=True), + ) + + self.scenarios_editor = ScenariosEditor(app) + self.scenarios_header = PanelHeader("Scenarios", theme.icons.scenarios) + self.scenarios_tab = ( + self.get_tab_button(theme.icons.scenarios, "Scenarios"), + ft.Column([self.scenarios_header, self.scenarios_editor], expand=True), + ) + + self.project_info_editor = ProjectInfoEditor(app) + self.project_info_header = PanelHeader("Project Info", theme.icons.project_info) + self.project_info_tab = ( + self.get_tab_button(theme.icons.project_info, "Project Info"), + ft.Column( + [self.project_info_header, self.project_info_editor], expand=True + ), + ) + + self.tabbed_panel = TabbedPanel( + selected_index=0, + tabs=[ + self.metrics_tab, + self.actions_tab, + self.scenarios_tab, + self.project_info_tab, + ], + on_tab_changed=self.redraw, + ) + + self.graph_editor = GraphEditor(app) + self.graph_panel = Panel(self.graph_editor) + + self.pathways_editor = PathwaysEditor(app) + self.pathways_header = PanelHeader("Pathways", ft.Icons.ACCOUNT_TREE_OUTLINED) + self.pathways_panel = Panel( + content=ft.Column( + expand=True, + alignment=ft.MainAxisAlignment.START, + controls=[ + self.pathways_header, + self.pathways_editor, + ], + ), + padding=theme.variables.panel_padding, + ) + + self.metrics_header.on_expand = lambda: self.on_editor_expanded( + self.tabbed_panel + ) + self.actions_header.on_expand = lambda: self.on_editor_expanded( + self.tabbed_panel + ) + self.scenarios_header.on_expand = lambda: self.on_editor_expanded( + self.tabbed_panel + ) + self.graph_editor.header.on_expand = lambda: self.on_editor_expanded( + self.graph_panel + ) + self.pathways_header.on_expand = lambda: self.on_editor_expanded( + self.pathways_panel + ) + + self.left_column = ft.Column( + expand=2, + spacing=theme.variables.panel_spacing, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + controls=[self.tabbed_panel], + ) + + self.right_column = ft.Column( + expand=3, + spacing=theme.variables.panel_spacing, + horizontal_alignment=ft.CrossAxisAlignment.STRETCH, + controls=[self.graph_panel, self.pathways_panel], + ) + + super().__init__( + expand=True, + spacing=theme.variables.panel_spacing, + controls=[self.left_column, self.right_column], + ) + + self.update_layout() + + def get_tab_button(self, icon: str, tooltip: str): + return ft.Container( + ft.Icon( + icon, + size=20, + color=theme.colors.primary_dark, + ), + width=50, + height=50, + tooltip=ft.Tooltip( + tooltip, + wait_duration=0, + bgcolor=theme.colors.primary_medium, + ), + ) + + def update_layout(self): + if self.expanded_editor is None: + self.left_column.visible = True + self.right_column.visible = True + self.graph_panel.visible = True + self.pathways_panel.visible = True + else: + self.left_column.visible = self.expanded_editor == self.tabbed_panel + self.pathways_panel.visible = self.expanded_editor == self.pathways_panel + self.graph_panel.visible = self.expanded_editor == self.graph_panel + self.right_column.visible = ( + self.pathways_panel.visible or self.graph_panel.visible + ) + + self.metrics_header.set_expanded(self.expanded_editor == self.tabbed_panel) + self.actions_header.set_expanded(self.expanded_editor == self.tabbed_panel) + self.scenarios_header.set_expanded(self.expanded_editor == self.tabbed_panel) + self.pathways_header.set_expanded(self.expanded_editor == self.pathways_panel) + self.graph_editor.header.set_expanded(self.expanded_editor == self.graph_panel) + + def on_editor_expanded(self, editor): + if self.expanded_editor == editor: + self.expanded_editor = None + else: + self.expanded_editor = editor + + self.update_layout() + self.update() + + def redraw(self): + if self.metrics_editor.page: + self.metrics_editor.redraw() + if self.scenarios_editor.page: + self.scenarios_editor.redraw() + if self.actions_editor.page: + self.actions_editor.redraw() + if self.project_info_editor.page: + self.project_info_editor.redraw() + self.pathways_editor.redraw() + self.graph_editor.redraw() + self.update() diff --git a/source/package/pathways_app/src/controls/pages/startup_page.py b/source/package/pathways_app/src/controls/pages/startup_page.py new file mode 100644 index 0000000..25cb56f --- /dev/null +++ b/source/package/pathways_app/src/controls/pages/startup_page.py @@ -0,0 +1,95 @@ +import flet as ft +from src import theme +from src.pathways_app import PathwaysApp + +from ..styled_button import StyledButton + + +class StartupPage(ft.Row): + def __init__(self, app: PathwaysApp): + self.app = app + + super().__init__( + expand=False, + alignment=ft.MainAxisAlignment.CENTER, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + self.panel = ft.Container( + ft.Column( + [ + ft.Text( + "Welcome to the Pathways Generator!", + text_align=ft.TextAlign.CENTER, + size=48, + expand=True, + ), + ft.Text( + "Use this tool to generate and analyze pathways.", + text_align=ft.TextAlign.CENTER, + size=18, + expand=True, + ), + ft.Row( + [ + StyledButton( + "Start a New Project", + ft.Icons.NOTE_ADD_OUTLINED, + size=20, + on_click=self.on_new_project, + ), + ft.Column( + [ + ft.Container( + height=16, + width=1, + bgcolor=theme.colors.primary_dark, + ), + ft.Text("or", size=16), + ft.Container( + height=16, + width=1, + bgcolor=theme.colors.primary_dark, + ), + ], + spacing=10, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + ), + StyledButton( + "Open an Existing Project", + ft.Icons.FILE_OPEN_OUTLINED, + size=20, + on_click=self.on_open_project, + ), + ], + expand=False, + spacing=20, + ), + ], + expand=False, + alignment=ft.MainAxisAlignment.CENTER, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + ), + expand=False, + padding=40, + bgcolor=theme.colors.primary_white, + border_radius=theme.variables.large_radius, + border=ft.border.all(1, theme.colors.primary_dark), + ) + + self.controls = [ + ft.Column( + [self.panel], + alignment=ft.MainAxisAlignment.CENTER, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + ) + ] + + def redraw(self): + pass + + def on_new_project(self, _): + self.app.new_project() + + def on_open_project(self, _): + self.app.open_project() diff --git a/source/package/pathways_app/src/controls/pages/wizard_page.py b/source/package/pathways_app/src/controls/pages/wizard_page.py new file mode 100644 index 0000000..e841b87 --- /dev/null +++ b/source/package/pathways_app/src/controls/pages/wizard_page.py @@ -0,0 +1,245 @@ +from enum import Enum +from typing import Callable + +import flet as ft +from src import theme +from src.pathways_app import PathwaysApp + +from ..editors.actions_editor import ActionsEditor +from ..editors.conditions_editor import ConditionsEditor +from ..editors.pathways_editor import PathwaysEditor +from ..editors.project_info_editor import ProjectInfoEditor +from ..header import SectionHeader +from ..panel import Panel +from ..styled_button import StyledButton + + +class WizardStepState(Enum): + INACTIVE = 0 + ACTIVE = 1 + COMPLETE = 2 + + +class WizardStepTab(ft.Container): + def __init__( + self, + name: str, + index: int, + total: int, + state=WizardStepState.INACTIVE, + on_click: Callable[["WizardStepTab"], None] | None = None, + ): + self.icon = ft.Icon() + self.index = index + self.total = total + self.state = state + self.click_handler = on_click + + super().__init__( + ft.Column( + [self.icon, ft.Text(name)], + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + spacing=4, + ), + padding=ft.padding.symmetric(10, 16), + on_click=self.on_click_event, + expand=True, + ) + self.set_state(state) + + def on_click_event(self, _): + if self.click_handler is None: + return + + self.click_handler(self) + + def set_state(self, state: WizardStepState): + self.icon.name = ( + ft.Icons.CHECK_CIRCLE_OUTLINE + if state is WizardStepState.COMPLETE + else ft.Icons.CIRCLE_OUTLINED + ) + self.icon.color = ( + theme.colors.completed + if state is WizardStepState.COMPLETE + else theme.colors.primary_dark + ) + + self.border = ft.border.only( + bottom=( + None + if state is WizardStepState.ACTIVE + else ft.BorderSide(1, theme.colors.primary_medium) + ), + left=( + None + if state is not WizardStepState.ACTIVE or self.index == 0 + else ft.BorderSide(1, theme.colors.primary_medium) + ), + right=( + None + if state is not WizardStepState.ACTIVE or self.index == self.total - 1 + else ft.BorderSide(1, theme.colors.primary_medium) + ), + ) + + self.bgcolor = ( + theme.colors.primary_lightest + if state is not WizardStepState.ACTIVE + else theme.colors.primary_white + ) + + +class WizardPage(ft.Row): + def __init__(self, app: PathwaysApp): + self.app = app + self.explainer_text = ft.Text("This is some sort of explanation") + self.finish_button = StyledButton("Finish", on_click=self.on_finish) + self.next_button = StyledButton("Next", on_click=self.on_next) + self.back_button = StyledButton("Back", on_click=self.on_back) + self.buttons = ft.Row( + [ + ft.Container(expand=True), + self.back_button, + self.next_button, + self.finish_button, + ] + ) + self.step_content = ft.Container(expand=True) + + tab_names = ["Project", "Conditions", "Actions", "Pathways"] + tab_count = len(tab_names) + self.step_tabs = [ + WizardStepTab(name, index, tab_count, on_click=self.on_tab_clicked) + for index, name in enumerate(tab_names) + ] + + self.step_explanations = [ + "To create a project, you'll need to fill in some critical info.", + "Conditions are the metrics you want to analyze the tipping points of.", + "Actions are the steps you can take to adapt to the conditions you defined earlier.", + "Pathways are ordered sequences of actions that adapt to a condition, up to a tipping point.", + ] + + self.selected_tab_index = 0 + + self.header = ft.Container( + ft.Row( + [ + SectionHeader( + ft.Icons.EDIT_DOCUMENT, + "New Project Wizard", + ), + ft.Container(expand=True), + ft.Container( + ft.Icon(ft.Icons.CLOSE), + on_click=self.on_close, + ), + ], + expand=True, + ), + bgcolor=theme.colors.primary_lightest, + padding=ft.padding.symmetric(10, 16), + border=ft.border.only(bottom=ft.BorderSide(1, theme.colors.primary_medium)), + ) + + self.body = ft.Container( + ft.Column( + [ + self.explainer_text, + self.step_content, + self.buttons, + ] + ), + padding=theme.variables.panel_padding, + expand=True, + ) + + super().__init__( + controls=[ + ft.Column( + [ + Panel( + ft.Column( + [ + self.header, + ft.Row(self.step_tabs, spacing=0, expand=False), + self.body, + ], + expand=True, + spacing=0, + ), + expand=False, + width=600, + height=600, + ) + ], + alignment=ft.MainAxisAlignment.CENTER, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + ) + ], + alignment=ft.MainAxisAlignment.CENTER, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + self.set_selected_tab(self.selected_tab_index) + + def set_selected_tab(self, index: int): + self.selected_tab_index = index + + for i, tab in enumerate(self.step_tabs): + tab.set_state( + WizardStepState.ACTIVE + if i == self.selected_tab_index + else ( + WizardStepState.COMPLETE + if i < self.selected_tab_index + else WizardStepState.INACTIVE + ) + ) + + match self.selected_tab_index: + case 0: + # self.step_content.content = ft.Container(expand=True, bgcolor="#333020") + self.step_content.content = ProjectInfoEditor(self.app) + case 1: + self.step_content.content = ConditionsEditor(self.app) + case 2: + self.step_content.content = ActionsEditor(self.app) + case 3: + self.step_content.content = PathwaysEditor(self.app) + + self.explainer_text.value = self.step_explanations[self.selected_tab_index] + self.next_button.visible = self.selected_tab_index < (len(self.step_tabs) - 1) + self.finish_button.visible = not self.next_button.visible + + def redraw(self): + for tab in self.step_tabs: + tab.update() + + self.step_content.update() + self.step_content.content.redraw() + self.buttons.update() + self.next_button.update() + self.finish_button.update() + self.update() + + def on_tab_clicked(self, tab: WizardStepTab): + self.set_selected_tab(tab.index) + self.redraw() + + def on_back(self, _): + if self.selected_tab_index == 0: + self.app.page.go("/") + else: + self.set_selected_tab(self.selected_tab_index - 1) + self.redraw() + + def on_next(self, _): + self.set_selected_tab(self.selected_tab_index + 1) + self.redraw() + + def on_close(self, _): + self.app.page.go("/") + + def on_finish(self, _): + self.app.page.go("/project") diff --git a/source/package/pathways_app/pathways_app/controls/panel.py b/source/package/pathways_app/src/controls/panel.py similarity index 77% rename from source/package/pathways_app/pathways_app/controls/panel.py rename to source/package/pathways_app/src/controls/panel.py index 69d374f..2391639 100644 --- a/source/package/pathways_app/pathways_app/controls/panel.py +++ b/source/package/pathways_app/src/controls/panel.py @@ -1,18 +1,26 @@ import flet as ft - -from .. import theme +from src import theme class Panel(ft.Container): - def __init__(self, content=None, padding=0): + def __init__( + self, + content=None, + padding=0, + expand=True, + width: int | None = None, + height: int | None = None, + ): super().__init__( - expand=True, + expand=expand, margin=0, padding=ft.padding.symmetric(padding, padding), bgcolor=theme.colors.primary_white, border=ft.border.all(1, theme.colors.primary_light), border_radius=theme.variables.small_radius, content=content, + width=width, + height=height, clip_behavior=ft.ClipBehavior.HARD_EDGE, ) # [ diff --git a/source/package/pathways_app/src/controls/panel_header.py b/source/package/pathways_app/src/controls/panel_header.py new file mode 100644 index 0000000..6b9e450 --- /dev/null +++ b/source/package/pathways_app/src/controls/panel_header.py @@ -0,0 +1,43 @@ +from typing import Callable + +import flet as ft +from src import theme + +from .header import SectionHeader + + +class PanelHeader(ft.Container): + def __init__( + self, + title: str, + icon: str | None = None, + on_expand: Callable[[], None] | None = None, + ): + self.on_expand = on_expand + self.icon = ft.Icon(ft.Icons.EXPAND, color=theme.colors.primary_dark, size=16) + self.set_expanded(False) + + super().__init__( + content=ft.Row( + [ + SectionHeader(icon, title), + ft.Container(expand=True), + ft.Container( + self.icon, + padding=0, + on_click=self.on_expand_clicked, + ), + ] + ), + padding=ft.padding.only(bottom=8), + border=ft.border.only(bottom=ft.BorderSide(1, theme.colors.primary_dark)), + ) + + def set_expanded(self, expanded: bool): + self.icon.name = theme.icons.minimize if expanded else theme.icons.maximize + + def on_expand_clicked(self, _): + if self.on_expand is None: + return + + self.on_expand() diff --git a/source/package/pathways_app/pathways_app/controls/sortable_header.py b/source/package/pathways_app/src/controls/sortable_header.py similarity index 53% rename from source/package/pathways_app/pathways_app/controls/sortable_header.py rename to source/package/pathways_app/src/controls/sortable_header.py index e6e5dc7..583fd72 100644 --- a/source/package/pathways_app/pathways_app/controls/sortable_header.py +++ b/source/package/pathways_app/src/controls/sortable_header.py @@ -1,25 +1,28 @@ from enum import Enum +from typing import Callable import flet as ft +from src import theme -from adaptation_pathways.app.model.sorting import SortingInfo, SortTarget - -from .. import theme +from adaptation_pathways.app.model.sorting import SortingInfo class SortMode(Enum): NONE = 0 - ASCENDING = 1 - DESCENDING = 2 + UNSORTED = 1 + ASCENDING = 2 + DESCENDING = 3 def get_icon(self): match self: case SortMode.ASCENDING: - return ft.icons.KEYBOARD_ARROW_UP + return ft.Icons.KEYBOARD_ARROW_UP case SortMode.DESCENDING: - return ft.icons.KEYBOARD_ARROW_DOWN + return ft.Icons.KEYBOARD_ARROW_DOWN + case SortMode.UNSORTED: + return ft.Icons.UNFOLD_MORE case _: - return ft.icons.UNFOLD_MORE + return None class SortableHeader(ft.Container): @@ -28,7 +31,12 @@ def __init__( sort_key: str, name: str, sort_mode: SortMode = SortMode.NONE, - on_sort=None, + on_sort: Callable[["SortableHeader"], None] | None = None, + expand: bool | int | None = True, + col: int | None = None, + bgcolor: str | None = None, + width: int | None = None, + height: int | None = None, ): self.sort_key = sort_key self.name = name @@ -37,20 +45,29 @@ def __init__( self.update_icon() super().__init__( - expand=True, + expand=expand, + col=col, content=ft.Row( - expand=True, controls=[ft.Text(name, expand=True), self.icon] + expand=True, + controls=[ + ft.Text(name, expand=True, style=theme.text.table_header), + self.icon, + ], ), + padding=theme.variables.table_cell_padding, + bgcolor=bgcolor, + width=width, + height=height, ) def on_click(_): new_sort_mode = ( SortMode.ASCENDING - if self.sort_mode == SortMode.NONE + if self.sort_mode == SortMode.UNSORTED else ( SortMode.DESCENDING if self.sort_mode == SortMode.ASCENDING - else SortMode.NONE + else SortMode.UNSORTED ) ) self.set_sort_mode(new_sort_mode) @@ -58,23 +75,23 @@ def on_click(_): if on_sort is not None: on_sort(self) - self.on_click = on_click + if self.sort_mode != SortMode.NONE: + self.on_click = on_click @classmethod def get_sort_mode(cls, sort_info: SortingInfo) -> SortMode: - if sort_info.target is SortTarget.NONE: - return SortMode.NONE return SortMode.ASCENDING if sort_info.ascending else SortMode.DESCENDING def set_sort_mode(self, sort_mode: SortMode): self.sort_mode = sort_mode self.update_icon() - # self.icon.update() + self.icon.update() def update_icon(self): self.icon.name = self.sort_mode.get_icon() self.icon.color = ( theme.colors.primary_lighter - if self.sort_mode is SortMode.NONE + if self.sort_mode is SortMode.UNSORTED else theme.colors.primary_dark ) + self.icon.visible = self.sort_mode != SortMode.NONE diff --git a/source/package/pathways_app/pathways_app/controls/styled_button.py b/source/package/pathways_app/src/controls/styled_button.py similarity index 71% rename from source/package/pathways_app/pathways_app/controls/styled_button.py rename to source/package/pathways_app/src/controls/styled_button.py index 48ef260..6964021 100644 --- a/source/package/pathways_app/pathways_app/controls/styled_button.py +++ b/source/package/pathways_app/src/controls/styled_button.py @@ -1,6 +1,5 @@ import flet as ft - -from .. import theme +from src import theme class StyledButton(ft.FilledButton): @@ -8,11 +7,12 @@ def __init__( self, text: str | None = None, icon: str | None = None, + size=14, on_click=None, ): super().__init__( style=theme.buttons.primary, - height=28, + height=size * 2, on_click=on_click, ) @@ -20,9 +20,11 @@ def __init__( controls=[], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER ) if icon is not None: - row.controls.append(ft.Icon(icon, color=theme.colors.true_white, size=16)) + row.controls.append(ft.Icon(icon, color=theme.colors.true_white, size=size)) if text is not None: - row.controls.append(ft.Text(text, style=theme.buttons.primary)) + row.controls.append(ft.Text(text, style=theme.buttons.primary, size=size)) - self.content = ft.Container(content=row, padding=ft.padding.symmetric(4, 8)) + self.content = ft.Container( + content=row, padding=ft.padding.symmetric(size * 0.25, size * 0.5) + ) diff --git a/source/package/pathways_app/src/controls/styled_dropdown.py b/source/package/pathways_app/src/controls/styled_dropdown.py new file mode 100644 index 0000000..6fbcf21 --- /dev/null +++ b/source/package/pathways_app/src/controls/styled_dropdown.py @@ -0,0 +1,85 @@ +# pylint: disable=too-many-arguments +import flet as ft +from src import theme +from src.utils import find_index + + +class StyledDropdown(ft.Dropdown): + def __init__( + self, + value: str, + options: list[ft.dropdown.Option], + option_icons: list[str] | None = None, + width: ft.OptionalNumber = None, + height: ft.OptionalNumber = 28, + text_style: ft.TextStyle | None = None, + on_change=None, + on_blur=None, + ): + super().__init__( + value=value, + text_style=text_style or theme.text.dropdown_normal, + expand=False, + options=[], + width=width, + bgcolor=theme.colors.true_white, + content_padding=ft.padding.symmetric(4, 8), + padding=0, + height=height, + border_color=theme.colors.primary_dark, + icon_enabled_color=theme.colors.primary_dark, + on_blur=on_blur, + ) + + self.option_icons = option_icons + + self.internal_icon = ft.Icon( + "", + color=theme.colors.primary_dark, + expand=False, + ) + self.internal_text = ft.Text("", style=self.text_style) + # self.suffix = ft.Container( + + # width=18, + # height=18, + # alignment=ft.alignment.center, + # bgcolor="#000000", + # ) + self.suffix_icon = ft.Icon( + ft.Icons.ARROW_DROP_DOWN, color=theme.colors.primary_medium + ) + self.prefix = ft.Row( + spacing=5, + controls=[self.internal_icon, self.internal_text], + ) + self.set_options(options, option_icons) + self.change_callback = on_change + self.on_change = self.on_value_changed + + def update_icon(self): + option_index = find_index(self.options, lambda el: el.key == self.value) + if option_index is None: + return + + option = self.options[option_index] + if self.option_icons is not None: + self.internal_icon.visible = True + self.internal_icon.name = self.option_icons[option_index] + else: + self.internal_icon.visible = False + self.internal_text.value = option.text or option.key + + def set_options( + self, options: list[ft.dropdown.Option], icons: list[str] | None = None + ): + self.options = options + self.option_icons = icons + self.update_icon() + + def on_value_changed(self, e): + self.update_icon() + self.update() + + if self.change_callback is not None: + self.change_callback(e) diff --git a/source/package/pathways_app/src/controls/styled_table.py b/source/package/pathways_app/src/controls/styled_table.py new file mode 100644 index 0000000..61a9d7f --- /dev/null +++ b/source/package/pathways_app/src/controls/styled_table.py @@ -0,0 +1,372 @@ +# pylint: disable=too-many-arguments +from typing import Callable + +import flet as ft +from src import theme + +from .sortable_header import SortableHeader, SortMode +from .styled_button import StyledButton + + +class TableColumn: + def __init__( + self, + label: str, + key: str | None = None, + expand: bool | int | None = True, + sortable: bool = True, + width: int | None = None, + alignment: ft.Alignment | None = ft.alignment.center_left, + ): + self.label = label + self.expand = expand + self.key = label if key is None else key + self.alignment = alignment + self.width = width + self.sortable = sortable + + +class TableCell: + def __init__( + self, + control: ft.Control, + sort_value: int | float | str | None = None, + ): + self.control = control + self.sort_value = sort_value + self.on_sort_value_changed: Callable[[], None] | None = None + + def set_sort_value(self, value: int | float | str | None): + self.sort_value = value + if self.on_sort_value_changed is not None: + self.on_sort_value_changed() + + +class TableRow: + def __init__(self, row_id: str, cells: list[TableCell], can_be_deleted=True): + self.row_id = row_id + self.cells = cells + self.can_be_deleted = can_be_deleted + + +class ColumnData: + def __init__( + self, + column: TableColumn, + height: int, + on_sort: Callable[[SortableHeader], None] | None = None, + ): + self.column = column + self.header = SortableHeader( + name=column.label, + sort_key=column.key, + sort_mode=(SortMode.UNSORTED if column.sortable else SortMode.NONE), + on_sort=on_sort, + expand=column.expand, + bgcolor=theme.colors.primary_lightest, + width=column.width, + height=height, + ) + + +class RowData: + def __init__( + self, + row: TableRow, + initial_index: int, + columns: list[ColumnData], + height: int, + on_selected: Callable[["RowData"], None] | None = None, + ): + self.row = row + self.initial_index = initial_index + self.on_selected = on_selected + self.checkbox = ft.Checkbox( + value=False, + on_change=self.on_checkbox_clicked, + ) + self.height = height + self.control = ft.Container( + ft.Row( + [ + ft.Container( + content=self.checkbox, + padding=theme.variables.table_cell_padding, + width=40, + height=height, + ), + *( + self._create_cell_control(cell, index, columns) + for index, cell in enumerate(self.row.cells) + ), + ], + spacing=0, + ), + expand=True, + ) + + def on_checkbox_clicked(self, _): + if self.on_selected is None: + return + self.on_selected(self) + + def _create_cell_control( + self, cell: TableCell, index: int, columns: list[ColumnData] + ): + column = columns[index].column + return ft.Container( + content=cell.control, + expand=column.expand, + height=self.height, + width=column.width, + padding=None, + ) + + +class StyledTable(ft.Container): + def __init__( + self, + columns: list[TableColumn], + rows: list[TableRow], + row_height=36, + sort_column_index: int | None = None, + sort_ascending: bool = True, + on_sorted: Callable[[], None] | None = None, + on_add: Callable[[], None] | None = None, + add_label="Add", + on_delete: Callable[[list[TableRow]], None] | None = None, + delete_label="Delete", + on_copy: Callable[[list[TableRow]], None] | None = None, + copy_label="Duplicate", + pre_operation_content: ft.Control | None = None, + show_checkboxes=False, + expand: bool | int | None = True, + ): + super().__init__( + expand=expand, + ) + + self.row_height = row_height + self.sort_column_index = sort_column_index + self.sort_ascending = sort_ascending + self.show_checkboxes = show_checkboxes + self.on_sorted = on_sorted + + self.on_add = on_add + self.on_copy = on_copy + self.on_delete = on_delete + + self.selected_row_ids: set[str] = set() + self.add_button = StyledButton( + add_label, + ft.Icons.ADD_CIRCLE_OUTLINE, + size=12, + on_click=self.on_add_clicked, + ) + self.copy_button = StyledButton( + copy_label, + ft.Icons.ADD_CIRCLE_OUTLINE, + size=12, + on_click=self.on_copy_clicked, + ) + self.delete_button = StyledButton( + delete_label, + ft.Icons.REMOVE_CIRCLE_OUTLINE, + size=12, + on_click=self.on_delete_clicked, + ) + + self.operation_row = ft.Row( + [ + self.delete_button, + self.copy_button, + self.add_button, + ], + expand=True, + alignment=ft.MainAxisAlignment.END, + ) + + if pre_operation_content is not None: + self.operation_row.controls.insert(0, pre_operation_content) + + self.operations = ft.Container(self.operation_row, height=36) + self.column_data: list[ColumnData] = [] + + self.header_row = ft.Container( + content=ft.Row([], expand=False, spacing=0), + bgcolor=theme.colors.primary_lightest, + border=ft.border.only( + bottom=ft.BorderSide(1, theme.colors.primary_medium), + ), + ) + + self.row_data: list[RowData] = [] + self.row_container = ft.Container( + content=ft.Column([], expand=True, scroll=ft.ScrollMode.ALWAYS, spacing=0), + expand=True, + ) + + self.content = ft.Column( + [self.operations, self.header_row, self.row_container], + spacing=0, + expand=True, + ) + + self.set_columns(columns) + self.set_rows(rows) + + def set_columns(self, columns: list[TableColumn]): + self.column_data = [ + ColumnData( + column=column, height=self.row_height, on_sort=self.on_header_sorted + ) + for column in columns + ] + self.select_all_checkbox = ft.Checkbox( + on_change=self.on_all_rows_selected, + ) + self.header_row.content.controls = [ + ft.Container( + content=self.select_all_checkbox, + width=40, + height=self.row_height, + bgcolor=theme.colors.primary_lightest, + padding=theme.variables.table_cell_padding, + ), + *(column.header for column in self.column_data), + ] + + def set_rows(self, rows: list[TableRow]): + self.row_data = [ + RowData( + row=row, + initial_index=index, + columns=self.column_data, + height=self.row_height, + on_selected=self.on_row_selected, + ) + for index, row in enumerate(rows) + ] + + for row in self.row_data: + for cell in row.row.cells: + cell.on_sort_value_changed = self.sort_rows + + self.sort_rows() + self.update_selected_rows() + + def sort_rows(self): + self.row_data.sort(key=self.get_sort_value, reverse=not self.sort_ascending) + self.row_container.content.controls = [row.control for row in self.row_data] + self.update_selected_rows() + + def get_sort_value(self, row: RowData): + if self.sort_column_index is None: + return row.initial_index + + cell = row.row.cells[self.sort_column_index] + return cell.sort_value + + def on_header_sorted(self, header: SortableHeader): + column_index = None + for index, column in enumerate(self.column_data): + if column.header == header: + column_index = index + + self.sort_ascending = header.sort_mode != SortMode.DESCENDING + self.sort_column_index = ( + None if header.sort_mode == SortMode.UNSORTED else column_index + ) + + for index, column in enumerate(self.column_data): + if index != self.sort_column_index: + column.header.set_sort_mode( + SortMode.NONE + if column.header.sort_mode is SortMode.NONE + else SortMode.UNSORTED + ) + + self.sort_rows() + self.update() + + if self.on_sorted is not None: + self.on_sorted() + + def on_row_selected(self, row: RowData): + if row.row.row_id in self.selected_row_ids: + self.selected_row_ids.remove(row.row.row_id) + else: + self.selected_row_ids.add(row.row.row_id) + self.update_selected_rows() + self.update() + + def on_all_rows_selected(self, _): + if self.select_all_checkbox.value: + for row in self.row_data: + self.selected_row_ids.add(row.row.row_id) + else: + self.selected_row_ids.clear() + + self.update_selected_rows() + self.update() + + def update_selected_rows(self): + self.copy_button.visible = ( + len(self.selected_row_ids) > 0 and self.on_copy is not None + ) + self.add_button.visible = ( + not self.copy_button.visible and self.on_add is not None + ) + + deletable_count = sum( + data.row.can_be_deleted and data.row.row_id in self.selected_row_ids + for data in self.row_data + ) + self.delete_button.visible = deletable_count > 0 and self.on_delete is not None + + self.select_all_checkbox.value = len(self.selected_row_ids) >= len( + self.row_data + ) + + for index, row in enumerate(self.row_data): + is_selected = row.row.row_id in self.selected_row_ids + row.control.bgcolor = ( + theme.colors.row_selected + if is_selected + else ( + theme.colors.true_white + if index % 2 == 0 + else theme.colors.off_white + ) + ) + row.checkbox.value = is_selected + + def on_add_clicked(self, _): + if self.on_add is None: + return + + self.on_add() + + def on_copy_clicked(self, _): + if self.on_copy is None: + return + + rows_to_copy = [ + data.row + for data in self.row_data + if data.row.row_id in self.selected_row_ids + ] + self.selected_row_ids.clear() + self.on_copy(rows_to_copy) + + def on_delete_clicked(self, _): + if self.on_delete is None: + return + + rows_to_delete = [ + data.row + for data in self.row_data + if data.row.can_be_deleted and data.row.row_id in self.selected_row_ids + ] + self.selected_row_ids.clear() + self.on_delete(rows_to_delete) diff --git a/source/package/pathways_app/src/controls/tabbed_panel.py b/source/package/pathways_app/src/controls/tabbed_panel.py new file mode 100644 index 0000000..3a588c0 --- /dev/null +++ b/source/package/pathways_app/src/controls/tabbed_panel.py @@ -0,0 +1,88 @@ +from typing import Callable + +import flet as ft +from src import theme + +from .header import SectionHeader + + +class TabbedPanel(ft.Container): + + def __init__( + self, + tabs: list[tuple[SectionHeader, ft.Control]], + selected_index: int, + on_tab_changed: Callable[[], None] | None = None, + ): + super().__init__( + expand=True, + bgcolor=theme.colors.primary_light, + border=ft.border.all(1, theme.colors.primary_light), + border_radius=ft.border_radius.all(theme.variables.small_radius), + ) + + self.selected_index = selected_index + self.tabs = tabs + self.on_tab_changed = on_tab_changed + + self.tab_buttons = [ + ft.Container( + content=tab[0], + padding=0, + opacity=self.get_opacity(index), + bgcolor=self.get_tab_bgcolor(index), + on_click=self.on_tab_clicked, + ) + for index, tab in enumerate(tabs) + ] + + self.tab_content = ft.Container( + expand=True, + content=tabs[selected_index][1], + margin=0, + padding=theme.variables.panel_padding, + bgcolor=theme.colors.primary_white, + ) + + self.content = ft.Row( + expand=True, + vertical_alignment=ft.CrossAxisAlignment.STRETCH, + controls=[ + ft.Container( + expand=False, + bgcolor=theme.colors.primary_lightest, + content=ft.Column( + expand=True, + controls=self.tab_buttons, + spacing=0, + ), + padding=0, + border=ft.border.only( + right=ft.BorderSide(1, theme.colors.primary_lighter) + ), + ), + self.tab_content, + ], + spacing=0, + ) + + def get_tab_bgcolor(self, index: int): + if self.selected_index == index: + return theme.colors.primary_white + return None + + def get_opacity(self, index: int): + return 1 if self.selected_index == index else 0.5 + + def on_tab_clicked(self, e): + self.selected_index = self.tab_buttons.index(e.control) + + for [index, tab] in enumerate(self.tab_buttons): + tab.bgcolor = self.get_tab_bgcolor(index) + tab.opacity = self.get_opacity(index) + + self.tab_content.content = self.tabs[self.selected_index][1] + self.update() + + if self.on_tab_changed is not None: + self.on_tab_changed() diff --git a/source/package/pathways_app/pathways_app/controls/unit_cell.py b/source/package/pathways_app/src/controls/unit_cell.py similarity index 90% rename from source/package/pathways_app/pathways_app/controls/unit_cell.py rename to source/package/pathways_app/src/controls/unit_cell.py index 31b7ee1..fe44b63 100644 --- a/source/package/pathways_app/pathways_app/controls/unit_cell.py +++ b/source/package/pathways_app/src/controls/unit_cell.py @@ -1,13 +1,14 @@ from typing import Callable import flet as ft +from src import theme from adaptation_pathways.app.model.metric import Metric, MetricUnit, default_units -from .. import theme +from .styled_table import TableCell -class MetricUnitCell(ft.DataCell): +class MetricUnitCell(TableCell): def __init__( self, metric: Metric, @@ -17,6 +18,7 @@ def __init__( def on_default_metric_selected(unit: str): self.metric.unit_or_default = unit + self.sort_value = metric.unit.display_name if on_unit_change is not None: on_unit_change(self) @@ -24,20 +26,24 @@ def create_unit_button(unit: MetricUnit): return ft.MenuItemButton( key=unit.symbol, content=ft.Text(unit.name, style=theme.text.normal), - style=theme.buttons.submenu, + style=theme.buttons.submenu_button, on_click=lambda e, u=unit: on_default_metric_selected(u.symbol), ) - def create_unit_submenu(name: str, controls: ft.Control): + def create_unit_submenu(name: str, controls: list[ft.Control]): return ft.SubmenuButton( - ft.Text(name, style=theme.text.normal), - style=theme.buttons.submenu, + ft.Text(name, style=theme.text.normal, expand=True), + style=theme.buttons.submenu_button, + menu_style=theme.buttons.nested_submenu, controls=controls, + expand=True, ) def create_submenu_header(text: str): return ft.Container( - ft.Text(value=text.upper(), style=theme.text.submenu_header), + content=ft.Text( + value=text.upper(), style=theme.text.submenu_header, expand=True + ), padding=ft.padding.only(left=4, right=4, top=8, bottom=4), bgcolor=theme.colors.true_white, expand=True, @@ -45,14 +51,14 @@ def create_submenu_header(text: str): super().__init__( ft.MenuBar( - style=theme.buttons.menu_button, + expand=True, + style=theme.buttons.menu_bar, controls=[ ft.SubmenuButton( - expand=False, - style=theme.buttons.submenu, content=ft.Text( metric.unit.display_name, style=theme.text.normal ), + menu_style=theme.buttons.submenu, controls=[ create_unit_submenu( "Length", @@ -187,7 +193,8 @@ def create_submenu_header(text: str): ], ), ], - ) + ), ], ) ) + self.sort_value = metric.unit.display_name diff --git a/source/package/pathways_app/src/data.py b/source/package/pathways_app/src/data.py new file mode 100644 index 0000000..8c0f51a --- /dev/null +++ b/source/package/pathways_app/src/data.py @@ -0,0 +1,107 @@ +import datetime +from uuid import uuid4 + +import flet as ft + +from adaptation_pathways.app.model.metric import ( + MetricEffect, + MetricValue, + MetricValueState, +) +from adaptation_pathways.app.model.pathways_project import PathwaysProject + + +def create_empty_project(name: str, project_id: str | None = None) -> PathwaysProject: + start_year = datetime.datetime.now().year + + project = PathwaysProject( + project_id=project_id or str(uuid4()), + name=name, + organization="", + start_year=start_year, + end_year=start_year + 100, + ) + + root_action = project.create_action("#999999", ft.Icons.HOME, name="Current") + project.root_action_id = root_action.id + + root_pathway = project.create_pathway(root_action.id) + project.root_pathway_id = root_pathway.id + + return project + + +def create_example_project() -> PathwaysProject: + project = PathwaysProject( + project_id="test-id", + name="Sea Level Rise Adaptation", + organization="Cork City Council", + start_year=2024, + end_year=2054, + graph_is_time=False, + ) + + metric_sea_level_rise = project.create_condition() + metric_sea_level_rise.name = "Sea Level Rise" + metric_sea_level_rise.unit_or_default = "cm" + + metric_cost = project.create_criteria() + metric_cost.name = "Cost" + metric_cost.unit_or_default = "€" + + metric_habitat_health = project.create_criteria() + metric_habitat_health.name = "Habitat Health" + metric_habitat_health.unit_or_default = "Impact" + + action_root = project.create_action("#999999", ft.Icons.HOME) + action_root.name = "Current" + action_root.metric_data = { + metric_sea_level_rise.id: MetricEffect(0), + metric_cost.id: MetricEffect(0), + metric_habitat_health.id: MetricEffect(0), + } + project.root_action_id = action_root.id + + action_sea_wall = project.create_action("#5A81DB", ft.Icons.WATER) + action_sea_wall.name = "Sea Wall" + action_sea_wall.metric_data = { + metric_sea_level_rise.id: MetricEffect(10), + metric_cost.id: MetricEffect(100000), + metric_habitat_health.id: MetricEffect(-2), + } + + action_pump = project.create_action("#44C1E1", ft.Icons.WATER_DROP_SHARP) + action_pump.name = "Pump" + action_pump.metric_data = { + metric_sea_level_rise.id: MetricEffect(5), + metric_cost.id: MetricEffect(50000), + metric_habitat_health.id: MetricEffect(-1), + } + + action_nature_based = project.create_action("#E0C74B", ft.Icons.PARK) + action_nature_based.name = "Nature-Based" + action_nature_based.metric_data = { + metric_sea_level_rise.id: MetricEffect(1), + metric_cost.id: MetricEffect(5000), + metric_habitat_health.id: MetricEffect(2), + } + + root_pathway = project.create_pathway(action_root.id, None) + root_pathway.metric_data[metric_sea_level_rise.id] = MetricValue(0) + root_pathway.metric_data[metric_cost.id] = MetricValue(0) + root_pathway.metric_data[metric_habitat_health.id] = MetricValue(0) + project.root_pathway_id = root_pathway.id + + project.create_pathway(action_pump.id, root_pathway.id) + project.create_pathway(action_sea_wall.id, root_pathway.id) + project.create_pathway(action_nature_based.id, root_pathway.id) + + best_case_scenario = project.create_scenario("Best Case") + best_case_scenario.set_data( + 2025, metric_sea_level_rise.id, MetricValue(0, MetricValueState.OVERRIDE) + ) + best_case_scenario.set_data( + 2050, metric_sea_level_rise.id, MetricValue(10, MetricValueState.OVERRIDE) + ) + project.values_scenario_id = best_case_scenario.id + return project diff --git a/source/package/pathways_app/src/pathways_app.py b/source/package/pathways_app/src/pathways_app.py new file mode 100644 index 0000000..58e2c1c --- /dev/null +++ b/source/package/pathways_app/src/pathways_app.py @@ -0,0 +1,149 @@ +import json +from typing import Callable + +import flet as ft +from pyodide.code import run_js +from src.config import Config +from src.data import create_empty_project + +from adaptation_pathways.app.service.project_service import ProjectService + + +is_web = True +try: + import flet_js +except ModuleNotFoundError: + is_web = False + + +class PathwaysApp: + def __init__(self, page: ft.Page): + self.page = page + self.project = create_empty_project("Blank Project") + self.project.organization = "Deltares" + self.file_opener = ft.FilePicker(on_result=self.on_file_opened) + self.file_saver = ft.FilePicker(on_result=self.on_file_saved) + self.page.overlay.append(self.file_opener) + self.page.overlay.append(self.file_saver) + + self.on_conditions_changed: list[Callable[[], None]] = [] + self.on_criteria_changed: list[Callable[[], None]] = [] + self.on_scenarios_changed: list[Callable[[], None]] = [] + self.on_actions_changed: list[Callable[[], None]] = [] + self.on_action_color_changed: list[Callable[[], None]] = [] + self.on_pathways_changed: list[Callable[[], None]] = [] + self.on_project_info_changed: list[Callable[[], None]] = [] + self.on_project_changed: list[Callable[[], None]] = [] + + if is_web: + old_send = flet_js.send + + def new_send(data: str): + message: dict = json.loads(data) + action = message.get("action", None) + if action == "open_project_result": + project_text = message.get("payload", None) + if project_text is None: + print("Open project failed: no payload received") + else: + self.on_project_text_received(project_text) + else: + old_send(data) + + flet_js.send = new_send + + def notify_conditions_changed(self): + for listener in self.on_conditions_changed: + listener() + + def notify_criteria_changed(self): + for listener in self.on_criteria_changed: + listener() + + def notify_scenarios_changed(self): + for listener in self.on_scenarios_changed: + listener() + + def notify_actions_changed(self): + for listener in self.on_actions_changed: + listener() + + def notify_action_color_changed(self): + for listener in self.on_action_color_changed: + listener() + + def notify_pathways_changed(self): + for listener in self.on_pathways_changed: + listener() + + def notify_project_changed(self): + for listener in self.on_project_changed: + listener() + + def notify_project_info_changed(self): + for listener in self.on_project_info_changed: + listener() + + def open_link(self, url: str): + self.page.launch_url(url) + + def new_project(self): + self.project = create_empty_project("New Project") + self.page.go("/wizard") + self.notify_project_changed() + + def open_project(self): + if is_web: + run_js('self.postMessage(\'{ "action": "open_project" }\');') + else: + self.file_opener.pick_files( + "Choose a Project File", + file_type=ft.FilePickerFileType.ANY, + allowed_extensions=[Config.project_extension], + allow_multiple=False, + ) + + def on_file_opened(self, event: ft.FilePickerResultEvent): + if len(event.files) == 0: + return + + with open(event.files[0].path, encoding="utf-8") as file: + text = file.read() + file.close() + + self.project = ProjectService.from_json(text) + self.page.go("/project") + self.notify_project_changed() + + def on_project_text_received(self, content: str): + self.project = ProjectService.from_json(content) + self.page.go("/project") + self.notify_project_changed() + + def save_project(self): + filename = f"{self.project.name}.{Config.project_extension}" + + if is_web: + project_json = ProjectService.to_json(self.project) + # Escapes quotes properly + project_json = json.dumps(project_json) + + message_json = f'{{ "action": "save_project", "filename":"{filename}", "content":{project_json} }}' + message_json = json.dumps(message_json) + + run_js(f"self.postMessage({message_json});") + else: + self.file_saver.save_file("Save Pathways Project", filename) + + def on_file_saved(self, event: ft.FilePickerResultEvent): + if event.path is None: + print("NO PATH") + return + + try: + with open(event.path, "w", encoding="utf-8") as file: + text = ProjectService.to_json(self.project) + file.write(text) + file.close() + except Exception as error: + print(error) diff --git a/source/package/pathways_app/pathways_app/theme.py b/source/package/pathways_app/src/theme.py similarity index 60% rename from source/package/pathways_app/pathways_app/theme.py rename to source/package/pathways_app/src/theme.py index ebde983..29a585b 100644 --- a/source/package/pathways_app/pathways_app/theme.py +++ b/source/package/pathways_app/src/theme.py @@ -13,7 +13,10 @@ class DefaultThemeColors: primary_darker = "#160E59" secondary_light = "#91E0EC" secondary_medium = "#48BDCF" - calculated_bg = "#E5E5E5" + calculated_bg = "#60CCCCEE" + calculated_icon = "#8888AA" + row_selected = "#D5F9FF" + completed = "#1BAC46" colors = DefaultThemeColors() @@ -34,35 +37,35 @@ class DefaultThemeColors: ] action_icons = [ - ft.icons.ACCESS_TIME_FILLED, - ft.icons.AC_UNIT, - ft.icons.ACCOUNT_BALANCE, - ft.icons.AIRPLANEMODE_ACTIVE, - ft.icons.BATTERY_FULL, - ft.icons.BRIGHTNESS_HIGH_SHARP, - ft.icons.BUILD_SHARP, - ft.icons.CALCULATE, - ft.icons.CAMERA_ALT, - ft.icons.CELL_TOWER, - ft.icons.CLOUD_SHARP, - ft.icons.CORONAVIRUS, - ft.icons.DEVICE_THERMOSTAT, - ft.icons.DIRECTIONS_BIKE, - ft.icons.DIRECTIONS_BOAT, - ft.icons.DIRECTIONS_CAR, - ft.icons.DIRECTIONS_BUS, - ft.icons.DIRECTIONS_TRAIN, - ft.icons.DIRECTIONS_WALK, - ft.icons.DISCOUNT_SHARP, - ft.icons.DIVERSITY_3_SHARP, - ft.icons.EMERGENCY_SHARP, - ft.icons.FAMILY_RESTROOM_SHARP, - ft.icons.FASTFOOD_SHARP, - ft.icons.FAVORITE, - ft.icons.FILTER_VINTAGE_OUTLINED, - ft.icons.FLASH_ON, - ft.icons.FLOOD_SHARP, - ft.icons.FOREST_SHARP, + ft.Icons.ACCESS_TIME_FILLED, + ft.Icons.AC_UNIT, + ft.Icons.ACCOUNT_BALANCE, + ft.Icons.AIRPLANEMODE_ACTIVE, + ft.Icons.BATTERY_FULL, + ft.Icons.BRIGHTNESS_HIGH_SHARP, + ft.Icons.BUILD_SHARP, + ft.Icons.CALCULATE, + ft.Icons.CAMERA_ALT, + ft.Icons.CELL_TOWER, + ft.Icons.CLOUD_SHARP, + ft.Icons.CORONAVIRUS, + ft.Icons.DEVICE_THERMOSTAT, + ft.Icons.DIRECTIONS_BIKE, + ft.Icons.DIRECTIONS_BOAT, + ft.Icons.DIRECTIONS_CAR, + ft.Icons.DIRECTIONS_BUS, + ft.Icons.DIRECTIONS_TRAIN, + ft.Icons.DIRECTIONS_WALK, + ft.Icons.DISCOUNT_SHARP, + ft.Icons.DIVERSITY_3_SHARP, + ft.Icons.EMERGENCY_SHARP, + ft.Icons.FAMILY_RESTROOM_SHARP, + ft.Icons.FASTFOOD_SHARP, + ft.Icons.FAVORITE, + ft.Icons.FILTER_VINTAGE_OUTLINED, + ft.Icons.FLASH_ON, + ft.Icons.FLOOD_SHARP, + ft.Icons.FOREST_SHARP, ] @@ -72,6 +75,8 @@ class DefaultThemeVariables: panel_spacing = 6 panel_padding = 10 table_cell_padding = ft.padding.symmetric(4, 8) + calculated_icon_size = 16 + icon_button_size = 16 variables = DefaultThemeVariables() @@ -101,6 +106,12 @@ class DefaultThemeTextStyles: color=colors.primary_dark, ) + dropdown_normal = ft.TextStyle( + font_family=font_family, + size=12, + color=colors.primary_dark, + ) + textfield = ft.TextStyle( font_family=font_family, size=12, color=colors.primary_dark ) @@ -109,6 +120,13 @@ class DefaultThemeTextStyles: font_family=font_family, size=12, color=colors.primary_lighter ) + menu_button = ft.TextStyle( + font_family=font_family, + size=14, + color=colors.true_white, + overflow=ft.TextOverflow.VISIBLE, + ) + button = ft.TextStyle( font_family=font_family_semibold, size=12, @@ -135,6 +153,14 @@ class DefaultThemeTextStyles: class DefaultThemeIcons: globe = "icons/icon_globe.svg" + actions = ft.Icons.CONSTRUCTION_OUTLINED + metrics = ft.Icons.TUNE + scenarios = ft.Icons.PUBLIC + project_info = ft.Icons.INFO_OUTLINE + maximize = ft.Icons.OPEN_IN_FULL + minimize = ft.Icons.CLOSE_FULLSCREEN + sidebar_open = ft.Icons.VIEW_SIDEBAR_OUTLINED + sidebar_closed = ft.Icons.VIEW_SIDEBAR_OUTLINED icon = "images/deltares-logo-white.png" @@ -149,6 +175,14 @@ class DefaultThemeButtons: shape=ft.RoundedRectangleBorder(radius=variables.small_radius), ) + menu_bar_button = ft.ButtonStyle( + bgcolor=colors.primary_dark, + color=colors.true_white, + padding=ft.padding.symmetric(0, 20), + shape=ft.RoundedRectangleBorder(radius=variables.small_radius), + mouse_cursor=ft.MouseCursor.CLICK, + ) + menu_button = ft.MenuStyle( alignment=ft.alignment.center_left, bgcolor=colors.true_white, @@ -160,14 +194,60 @@ class DefaultThemeButtons: mouse_cursor=ft.MouseCursor.CELL, ) - submenu = ft.ButtonStyle( - color=colors.primary_dark, + menu_bar = ft.MenuStyle( + alignment=ft.alignment.center_left, + bgcolor="#00000000", + padding=ft.padding.symmetric(0, 0), + shape=ft.RoundedRectangleBorder(radius=0), + shadow_color="#00000000", + side=None, + mouse_cursor=ft.MouseCursor.CLICK, + ) + + submenu = ft.MenuStyle( + alignment=ft.alignment.bottom_left, bgcolor=colors.true_white, + shadow_color=colors.true_white, + surface_tint_color=colors.true_white, + padding=ft.padding.symmetric(0, 0), + shape=ft.RoundedRectangleBorder(radius=variables.small_radius), + # side=ft.BorderSide(1, color=colors.primary_medium), + mouse_cursor=ft.MouseCursor.CELL, + # alignment=ft.alignment.center_left, + # bgcolor=colors.true_white, + # shape=ft.RoundedRectangleBorder(radius=variables.small_radius), + # side=ft.BorderSide(1, color=colors.primary_medium), + ) + + submenu_button = ft.ButtonStyle( + color=colors.primary_dark, shape=ft.RoundedRectangleBorder(radius=variables.small_radius), padding=ft.padding.symmetric(4, 6), # side=ft.BorderSide(1, color=colors.primary_medium), ) + nested_submenu = ft.MenuStyle( + alignment=ft.alignment.top_right, + bgcolor=colors.true_white, + shadow_color=colors.true_white, + surface_tint_color=colors.true_white, + padding=ft.padding.symmetric(0, 0), + shape=ft.RoundedRectangleBorder(radius=variables.small_radius), + # side=ft.BorderSide(1, color=colors.primary_medium), + mouse_cursor=ft.MouseCursor.CELL, + # alignment=ft.alignment.center_left, + # bgcolor=colors.true_white, + # shape=ft.RoundedRectangleBorder(radius=variables.small_radius), + # side=ft.BorderSide(1, color=colors.primary_medium), + ) + + unit_menu = ft.ButtonStyle( + color=colors.primary_dark, + shape=ft.RoundedRectangleBorder(radius=0), + padding=ft.padding.symmetric(0, 0), + # side=ft.BorderSide(1, color=colors.primary_medium), + ) + buttons = DefaultThemeButtons() diff --git a/source/package/pathways_app/src/utils.py b/source/package/pathways_app/src/utils.py new file mode 100644 index 0000000..3c96862 --- /dev/null +++ b/source/package/pathways_app/src/utils.py @@ -0,0 +1,11 @@ +from typing import Callable, TypeVar + + +T = TypeVar("T") + + +def find_index(element_list: list[T], pred: Callable[[T], bool]) -> int | None: + for index, value in enumerate(element_list): + if pred(value): + return index + return None