first commit
							
								
								
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,6 @@ | ||||
| venv/* | ||||
| .directory | ||||
| *.pyc | ||||
| __pycache__/ | ||||
| .mypy_cache/ | ||||
| cromulant.db | ||||
							
								
								
									
										18
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,18 @@ | ||||
| repos: | ||||
| -   repo: https://github.com/pre-commit/mirrors-mypy | ||||
|     rev: v1.11.0 | ||||
|     hooks: | ||||
|     -   id: mypy | ||||
|         files: "^cromulant/.*" | ||||
|         args: [--strict, --strict, --strict, cromulant/main.py] | ||||
| -   repo: https://github.com/charliermarsh/ruff-pre-commit | ||||
|     rev: v0.5.5 | ||||
|     hooks: | ||||
|     -   id: ruff | ||||
|         name: ruff check | ||||
|         entry: ruff check | ||||
|         files: ^cromulant/ | ||||
|     -   id: ruff | ||||
|         name: ruff format | ||||
|         entry: ruff format | ||||
|         files: ^cromulant/ | ||||
							
								
								
									
										118
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,118 @@ | ||||
| # Cromulant | ||||
|  | ||||
| <img src="cromulant/img/status.jpg" width="200"> | ||||
|  | ||||
| [Click here for screenshots](screenshots.md) | ||||
|  | ||||
| ## What is this? | ||||
|  | ||||
| This is a kind of toy you can use for your amusement. | ||||
|  | ||||
| It requires minimal interaction, most things happen automatically. | ||||
|  | ||||
| You might want to keep it running in some tiled layout. | ||||
|  | ||||
| ## Usage | ||||
|  | ||||
| You start with a set of `25` to `250` random ants (`100` by default). | ||||
|  | ||||
| You can specify this anytime through `Restart`. When you restart everything resets to zero like triumphs and hits. | ||||
|  | ||||
| There are `1000` names available. This is used as the pool of names to select randomly. | ||||
|  | ||||
| Every x minutes or seconds a new update from a random ant appears. | ||||
|  | ||||
| The content of the update depends on a random number. | ||||
|  | ||||
| It can be a triumph, a hit, travel, thought, sentence. | ||||
|  | ||||
| The ant with the highest score is shown in the footer. | ||||
|  | ||||
| Ants get merged and replaced over time. | ||||
|  | ||||
| All of this happens automatically, though you can manually force actions | ||||
| by using the mouse on the portraits or main menu. Try click and middle click. | ||||
|  | ||||
| Read [Algorithm](#algorithm) for more information about the mechanics. | ||||
|  | ||||
| ## Installation | ||||
|  | ||||
| First make sure you have `qt` installed in your system. | ||||
|  | ||||
| In arch you can do this with: `sudo pacman -S qt6-base` | ||||
|  | ||||
| In ubuntu/debian you can do this with: `sudo apt install qt6-base-dev` | ||||
|  | ||||
| ### Quick Installation | ||||
|  | ||||
| If you have `pipx` and `linux` installed you can use the following command: | ||||
|  | ||||
| ```sh | ||||
| pipx install git+https://github.com/madprops/cromulant --force | ||||
| ``` | ||||
|  | ||||
| ### Advanced Installation | ||||
|  | ||||
| 1) Clone this repo. | ||||
|  | ||||
| 2) python -m venv venv | ||||
|  | ||||
| 3) venv/bin/pip install -r requirements.txt | ||||
|  | ||||
| 4) Use `run.sh` or `venv/bin/python -m cromulant.main` | ||||
|  | ||||
| 5) (Optional) Manually create desktop entries and icons for the application. | ||||
|  | ||||
| ## Algorithm <a name="algorithm"></a> | ||||
|  | ||||
| A random ant is picked based on weights (oldest update date weighs more). | ||||
| More weight means something is more likely to get picked. | ||||
| Then a random number between 0 and length-of-methods-1 is picked. | ||||
| For each number an action happens to produce an update. | ||||
|  | ||||
| Words (sentences) have more weight compared to the rest of the update methods. | ||||
| Some methods roll another number to pick the outcome like in the case of `think` | ||||
| where there are 3 `think` types, these can also have custom weights. | ||||
|  | ||||
| The top score is calculated on every new update. | ||||
| The score is calculated as (Triumph - Hits). | ||||
| If multiple ants have the same score, the oldest one wins. | ||||
| The ant with the top score is shown in the footer. | ||||
| The top ant uses a special portrait on updates. | ||||
|  | ||||
| For merge, the words of each name are used. | ||||
| They get filled with random words if less than 2 words. | ||||
| One word from each set is picked randomly. | ||||
| The triumph and hits from each ant get combined for the new ant. | ||||
| The original ants get terminated and the merged one hatches. | ||||
| An extra random ant is hatched to fill the gap. | ||||
|  | ||||
| ## Storage | ||||
|  | ||||
| The state of ants is stored in `~/.local/share/cromulant/ants.json` | ||||
|  | ||||
| The settings file is stored in `~/.config/cromulant/settings.json` | ||||
|  | ||||
| Or the equivalents in non-linux systems. | ||||
|  | ||||
| There is a command line argument to define a custom location for the ants state file. | ||||
| This means you can have multiple states to save/load. | ||||
|  | ||||
| There is a command line argument to define a custom location for the names list. | ||||
| This means you can use this with another set of names. | ||||
| If not enough names are provided the remaining ants are created with random words. | ||||
|  | ||||
| ## The Name | ||||
|  | ||||
| I read the word [cromulent](https://www.merriam-webster.com/wordplay/what-does-cromulent-mean) being used somewhere which turned out to be invented by The Simpsons. | ||||
|  | ||||
|  | ||||
| I created a new programming project to practice/study and tried to use that word for the name but made a typo. | ||||
|  | ||||
| I liked the typo and made a game around it. | ||||
|  | ||||
| --- | ||||
|  | ||||
| [Command line arguments](arguments.md) | ||||
|  | ||||
| [Click here for more](more.md) | ||||
							
								
								
									
										189
									
								
								arguments.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,189 @@ | ||||
| # Arguments | ||||
|  | ||||
| Here are all the available command line arguments: | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### version | ||||
|  | ||||
| Check the version of the program | ||||
|  | ||||
| Action: version | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### names | ||||
|  | ||||
| Path to a JSON file with a list of names. Use these instead of the default ones | ||||
|  | ||||
| Type: str | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### ants | ||||
|  | ||||
| Path to a JSON file with ants data. Use this instead of the default one | ||||
|  | ||||
| Type: str | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### no-images | ||||
|  | ||||
| Don't show the images on the left | ||||
|  | ||||
| Action: store_false | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### no-header | ||||
|  | ||||
| Don't show the header controls | ||||
|  | ||||
| Action: store_false | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### no-footer | ||||
|  | ||||
| Don't show the footer controls | ||||
|  | ||||
| Action: store_false | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### no-intro | ||||
|  | ||||
| Don't show the intro message | ||||
|  | ||||
| Action: store_false | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### title | ||||
|  | ||||
| Custom title for the window | ||||
|  | ||||
| Default: [Empty string] | ||||
|  | ||||
| Type: str | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### width | ||||
|  | ||||
| The width of the window in pixels | ||||
|  | ||||
| Default: 0 | ||||
|  | ||||
| Type: int | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### height | ||||
|  | ||||
| The height of the window in pixels | ||||
|  | ||||
| Default: 0 | ||||
|  | ||||
| Type: int | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### program | ||||
|  | ||||
| The internal name of the program | ||||
|  | ||||
| Default: [Empty string] | ||||
|  | ||||
| Type: str | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### speed | ||||
|  | ||||
| Use this update speed | ||||
|  | ||||
| Default: [Empty string] | ||||
|  | ||||
| Choices: "fast", "normal", "slow", "paused" | ||||
|  | ||||
| Type: str | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### clean | ||||
|  | ||||
| Start with clean ants data | ||||
|  | ||||
| Default: False | ||||
|  | ||||
| Action: store_true | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### fast-seconds | ||||
|  | ||||
| The number of seconds between fast updates | ||||
|  | ||||
| Default: 0 | ||||
|  | ||||
| Type: int | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### normal-minutes | ||||
|  | ||||
| The number of minutes between normal updates | ||||
|  | ||||
| Default: 0.0 | ||||
|  | ||||
| Type: float | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### slow-minutes | ||||
|  | ||||
| The number of minutes between slow updates | ||||
|  | ||||
| Default: 0.0 | ||||
|  | ||||
| Type: float | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### argdoc | ||||
|  | ||||
| Make the arguments document and exit | ||||
|  | ||||
| Default: False | ||||
|  | ||||
| Action: store_true | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### score | ||||
|  | ||||
| Show the score on triumph or hits instead of the total of each | ||||
|  | ||||
| Default: False | ||||
|  | ||||
| Action: store_true | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### mono | ||||
|  | ||||
| Use a monospace font | ||||
|  | ||||
| Default: False | ||||
|  | ||||
| Action: store_true | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### no-fade | ||||
|  | ||||
| Don't apply a fade-in effect on new updates | ||||
|  | ||||
| Action: store_false | ||||
							
								
								
									
										0
									
								
								cromulant/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										324
									
								
								cromulant/ants.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,324 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import re | ||||
| import random | ||||
| import itertools | ||||
| from typing import ClassVar, Any | ||||
|  | ||||
| from .config import Config | ||||
| from .args import Args | ||||
| from .utils import Utils | ||||
| from .storage import Storage | ||||
|  | ||||
|  | ||||
| class Ant: | ||||
|     def __init__(self) -> None: | ||||
|         now = Utils.now() | ||||
|         self.created = now | ||||
|         self.updated = now | ||||
|         self.name = "" | ||||
|         self.status = "" | ||||
|         self.method = "hatched" | ||||
|         self.triumph = 0 | ||||
|         self.hits = 0 | ||||
|  | ||||
|     def to_dict(self) -> dict[str, Any]: | ||||
|         return { | ||||
|             "created": self.created, | ||||
|             "updated": self.updated, | ||||
|             "name": self.name, | ||||
|             "status": self.status, | ||||
|             "method": self.method, | ||||
|             "hits": self.hits, | ||||
|             "triumph": self.triumph, | ||||
|         } | ||||
|  | ||||
|     def from_dict(self, data: dict[str, Any]) -> None: | ||||
|         self.created = data["created"] | ||||
|         self.updated = data["updated"] | ||||
|         self.name = data["name"] | ||||
|         self.status = data["status"] | ||||
|         self.method = data["method"] | ||||
|         self.hits = data["hits"] | ||||
|         self.triumph = data["triumph"] | ||||
|  | ||||
|     def get_name(self) -> str: | ||||
|         return self.name or "Nameless" | ||||
|  | ||||
|     def get_age(self) -> str: | ||||
|         now = Utils.now() | ||||
|         return Utils.time_ago(self.created, now) | ||||
|  | ||||
|     def describe(self) -> None: | ||||
|         Utils.print(f"Name is {self.get_name()}") | ||||
|         Utils.print(f"It hatched {self.get_age()}") | ||||
|  | ||||
|     def get_score(self) -> int: | ||||
|         return self.triumph - self.hits | ||||
|  | ||||
|     def tooltip(self) -> str: | ||||
|         tooltip = "" | ||||
|         tooltip += f"Updated: {Utils.to_date(self.updated)}" | ||||
|         tooltip += f"\nCreated: {Utils.to_date(self.created)}" | ||||
|         tooltip += f"\nTriumph: {self.triumph} | Hits: {self.hits}" | ||||
|         tooltip += "\nClick to Terminate" | ||||
|         tooltip += "\nMiddle Click to Merge" | ||||
|         return tooltip | ||||
|  | ||||
|     def get_status(self) -> str: | ||||
|         from .game import Method | ||||
|  | ||||
|         if (not self.status) and (not self.method): | ||||
|             return "No update yet" | ||||
|  | ||||
|         status = self.status | ||||
|  | ||||
|         if self.method == Method.triumph: | ||||
|             if Args.score: | ||||
|                 total = f"(Score: {self.get_score()})" | ||||
|             else: | ||||
|                 total = f"({self.triumph} total)" | ||||
|  | ||||
|             status = f"{Config.triumph_icon} {Config.triumph_message} {total}" | ||||
|  | ||||
|         elif self.method == Method.hit: | ||||
|             if Args.score: | ||||
|                 total = f"(Score: {self.get_score()})" | ||||
|             else: | ||||
|                 total = f"({self.hits} total)" | ||||
|  | ||||
|             status = f"{Config.hit_icon} {Config.hit_message} {total}" | ||||
|  | ||||
|         elif self.method == Method.think: | ||||
|             status = f"Thinking about {status}" | ||||
|  | ||||
|         elif self.method == Method.travel: | ||||
|             status = f"Traveling to {status}" | ||||
|  | ||||
|         return status | ||||
|  | ||||
|  | ||||
| class Ants: | ||||
|     ants: ClassVar[list[Ant]] = [] | ||||
|     top: ClassVar[Ant | None] = None | ||||
|  | ||||
|     @staticmethod | ||||
|     def prepare() -> None: | ||||
|         Ants.get() | ||||
|         Ants.check() | ||||
|         Ants.get_top() | ||||
|  | ||||
|     @staticmethod | ||||
|     def check() -> None: | ||||
|         if not Ants.ants: | ||||
|             Ants.populate(Config.default_population) | ||||
|  | ||||
|     @staticmethod | ||||
|     def hatch(num: int = 1, ignore: list[str] | None = None) -> None: | ||||
|         from .game import Game | ||||
|  | ||||
|         for _ in range(num): | ||||
|             ant = Ant() | ||||
|             ant.name = Ants.random_name(ignore) | ||||
|             Ants.ants.append(ant) | ||||
|             Game.update(ant) | ||||
|  | ||||
|         Ants.on_change() | ||||
|  | ||||
|     @staticmethod | ||||
|     def on_change() -> None: | ||||
|         from .game import Game | ||||
|  | ||||
|         Ants.get_top() | ||||
|         Game.info() | ||||
|         Ants.save() | ||||
|  | ||||
|     @staticmethod | ||||
|     def random_ant(ignore: list[Ant] | None = None) -> Ant | None: | ||||
|         if ignore: | ||||
|             ants = [a for a in Ants.ants if a not in ignore] | ||||
|         else: | ||||
|             ants = Ants.ants | ||||
|  | ||||
|         return random.choice(ants) | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_names() -> list[str]: | ||||
|         return [ant.name for ant in Ants.ants] | ||||
|  | ||||
|     @staticmethod | ||||
|     def save() -> None: | ||||
|         Storage.save_ants(Ants.ants) | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_next() -> Ant | None: | ||||
|         now = Utils.now() | ||||
|         ages = [(now - ant.updated) for ant in Ants.ants] | ||||
|  | ||||
|         # Normalize ages to create weights | ||||
|         total_age = sum(ages) | ||||
|  | ||||
|         if total_age == 0: | ||||
|             weights = [1] * len(Ants.ants)  # If all ages are zero, use equal weights | ||||
|         else: | ||||
|             weights = [ | ||||
|                 int((age / total_age) * 1000) for age in ages | ||||
|             ]  # Scale and cast to int | ||||
|  | ||||
|         # Perform weighted random selection | ||||
|         return random.choices(Ants.ants, weights=weights, k=1)[0] | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_current() -> Ant | None: | ||||
|         return max(Ants.ants, key=lambda ant: ant.updated) | ||||
|  | ||||
|     @staticmethod | ||||
|     def set_status(ant: Ant, status: str, method: str) -> None: | ||||
|         from .game import Game | ||||
|  | ||||
|         status = status.strip() | ||||
|         ant.status = status | ||||
|         ant.method = method | ||||
|         ant.updated = Utils.now() | ||||
|  | ||||
|         Game.update(ant) | ||||
|         Ants.on_change() | ||||
|  | ||||
|     @staticmethod | ||||
|     def get() -> None: | ||||
|         if Args.clean: | ||||
|             objs = [] | ||||
|         else: | ||||
|             objs = Storage.get_ants() | ||||
|  | ||||
|         for obj in objs: | ||||
|             ant = Ant() | ||||
|             ant.from_dict(obj) | ||||
|             Ants.ants.append(ant) | ||||
|  | ||||
|     @staticmethod | ||||
|     def populate(num: int) -> None: | ||||
|         Ants.clear() | ||||
|         Ants.hatch(num) | ||||
|  | ||||
|     @staticmethod | ||||
|     def random_name(ignore: list[str] | None = None) -> str: | ||||
|         names = Ants.get_names() | ||||
|  | ||||
|         if ignore: | ||||
|             for name in ignore: | ||||
|                 if name not in names: | ||||
|                     names.append(name) | ||||
|  | ||||
|         return Utils.random_name(names) | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_top() -> None: | ||||
|         top: Ant | None = None | ||||
|         top_score = 0 | ||||
|  | ||||
|         for ant in Ants.ants: | ||||
|             score = ant.get_score() | ||||
|  | ||||
|             if (not top) or (score > top_score): | ||||
|                 top = ant | ||||
|                 top_score = score | ||||
|             elif score == top_score: | ||||
|                 if ant.created < top.created: | ||||
|                     top = ant | ||||
|  | ||||
|         if not top: | ||||
|             return | ||||
|  | ||||
|         Ants.top = top | ||||
|  | ||||
|     @staticmethod | ||||
|     def merge(ant_1: Ant | None = None) -> bool: | ||||
|         from .game import Game | ||||
|  | ||||
|         def split(ant: Ant) -> list[str]: | ||||
|             return re.split(r"[ -]", ant.name) | ||||
|  | ||||
|         def remove(words: list[str], ignore: list[str]) -> list[str]: | ||||
|             return [word for word in words if word.lower() not in ignore] | ||||
|  | ||||
|         def fill(words: list[str]) -> list[str]: | ||||
|             words = remove(words, ["of", "de", "da", "the"]) | ||||
|  | ||||
|             if len(words) < 2: | ||||
|                 n = random.randint(1, 2) | ||||
|  | ||||
|                 if n == 1: | ||||
|                     words = Utils.random_words(2 - len(words)) | ||||
|                 else: | ||||
|                     words = Utils.make_words(2 - len(words)) | ||||
|  | ||||
|                 words.extend(words) | ||||
|  | ||||
|             return [Utils.capitalize(word) for word in words] | ||||
|  | ||||
|         if not ant_1: | ||||
|             ant_1 = Ants.random_ant() | ||||
|  | ||||
|         if not ant_1: | ||||
|             return False | ||||
|  | ||||
|         ant_2 = Ants.random_ant([ant_1]) | ||||
|  | ||||
|         if not ant_2: | ||||
|             return False | ||||
|  | ||||
|         words_1 = split(ant_1) | ||||
|         words_2 = split(ant_2) | ||||
|         words_1 = fill(words_1) | ||||
|         words_2 = fill(words_2) | ||||
|  | ||||
|         name = "" | ||||
|         names = Ants.get_names() | ||||
|         combinations = list(itertools.product(words_1, words_2)) | ||||
|         random.shuffle(combinations) | ||||
|  | ||||
|         for combo in combinations: | ||||
|             possible = f"{combo[0]} {combo[1]}" | ||||
|  | ||||
|             if (possible == ant_1.name) or (possible == ant_2.name): | ||||
|                 continue | ||||
|  | ||||
|             if (possible in names) or (possible in Utils.names): | ||||
|                 continue | ||||
|  | ||||
|             name = possible | ||||
|             break | ||||
|  | ||||
|         if not name: | ||||
|             return False | ||||
|  | ||||
|         Ants.set_terminated(ant_1) | ||||
|         Ants.set_terminated(ant_2) | ||||
|  | ||||
|         ant = Ant() | ||||
|         ant.name = name | ||||
|         ant.triumph = ant_1.triumph + ant_2.triumph | ||||
|         ant.hits = ant_1.hits + ant_2.hits | ||||
|  | ||||
|         Ants.ants.append(ant) | ||||
|         Game.update(ant) | ||||
|         Ants.hatch(ignore=[ant_1.name, ant_2.name]) | ||||
|         return True | ||||
|  | ||||
|     @staticmethod | ||||
|     def clear() -> None: | ||||
|         Ants.ants = [] | ||||
|  | ||||
|     @staticmethod | ||||
|     def terminate(ant: Ant) -> None: | ||||
|         Ants.set_terminated(ant) | ||||
|         Ants.hatch(ignore=[ant.name]) | ||||
|  | ||||
|     @staticmethod | ||||
|     def set_terminated(ant: Ant) -> None: | ||||
|         from .game import Game | ||||
|  | ||||
|         ant.method = "terminated" | ||||
|         Game.update(ant) | ||||
|         Ants.ants.remove(ant) | ||||
							
								
								
									
										216
									
								
								cromulant/args.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,216 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import argparse | ||||
| from pathlib import Path | ||||
| from typing import Any | ||||
|  | ||||
| from .config import Config | ||||
| from .utils import Utils | ||||
| from .argspec import ArgSpec | ||||
|  | ||||
|  | ||||
| class Args: | ||||
|     names: Path | None = None | ||||
|     ants: Path | None = None | ||||
|     images: bool = True | ||||
|     header: bool = True | ||||
|     footer: bool = True | ||||
|     intro: bool = True | ||||
|     title: str = "" | ||||
|     width: int = 0 | ||||
|     height: int = 0 | ||||
|     program: str = "" | ||||
|     speed: str = "" | ||||
|     clean: bool = False | ||||
|     fast_seconds: int = 0 | ||||
|     normal_minutes: float = 0.0 | ||||
|     slow_minutes: float = 0.0 | ||||
|     argdoc: bool = False | ||||
|     score: bool = False | ||||
|     mono: bool = False | ||||
|     fade: bool = True | ||||
|  | ||||
|     @staticmethod | ||||
|     def prepare() -> None: | ||||
|         ArgSpec.prepare() | ||||
|         ArgParser.prepare(Config.title, ArgSpec.arguments) | ||||
|  | ||||
|         for attr_name, attr_value in vars(Args).items(): | ||||
|             ArgSpec.defaults[attr_name] = attr_value | ||||
|  | ||||
|         other_name = [ | ||||
|             ("no_images", "images"), | ||||
|             ("no_header", "header"), | ||||
|             ("no_footer", "footer"), | ||||
|             ("no_intro", "intro"), | ||||
|             ("no_fade", "fade"), | ||||
|         ] | ||||
|  | ||||
|         for r_item in other_name: | ||||
|             ArgParser.get_value(*r_item) | ||||
|  | ||||
|         normals = [ | ||||
|             "title", | ||||
|             "width", | ||||
|             "height", | ||||
|             "program", | ||||
|             "speed", | ||||
|             "clean", | ||||
|             "fast_seconds", | ||||
|             "normal_minutes", | ||||
|             "slow_minutes", | ||||
|             "argdoc", | ||||
|             "score", | ||||
|             "mono", | ||||
|         ] | ||||
|  | ||||
|         for n_item in normals: | ||||
|             ArgParser.get_value(n_item) | ||||
|  | ||||
|         paths = [ | ||||
|             "names", | ||||
|             "ants", | ||||
|         ] | ||||
|  | ||||
|         for p_item in paths: | ||||
|             ArgParser.get_value(p_item, path=True) | ||||
|  | ||||
|     @staticmethod | ||||
|     def make_argdoc() -> None: | ||||
|         from .utils import Utils | ||||
|         from .storage import Storage | ||||
|  | ||||
|         text = Args.argtext() | ||||
|         Storage.save_arguments(text) | ||||
|         Utils.print("Saved arguments document") | ||||
|  | ||||
|     @staticmethod | ||||
|     def argtext(filter_text: str | None = None) -> str: | ||||
|         sep = "\n\n---\n\n" | ||||
|         text = "" | ||||
|         filter_lower = "" | ||||
|  | ||||
|         if not filter_text: | ||||
|             text = "# Arguments\n\n" | ||||
|             text += "Here are all the available command line arguments:" | ||||
|         else: | ||||
|             filter_lower = filter_text.lower() | ||||
|  | ||||
|         for key in ArgSpec.arguments: | ||||
|             if key == "string_arg": | ||||
|                 continue | ||||
|  | ||||
|             arg = ArgSpec.arguments[key] | ||||
|             info = arg.get("help", "") | ||||
|  | ||||
|             if filter_text: | ||||
|                 if filter_lower not in key.lower(): | ||||
|                     if filter_lower not in info.lower(): | ||||
|                         continue | ||||
|  | ||||
|             text += sep | ||||
|             name = key.replace("_", "-") | ||||
|             text += f"### {name}" | ||||
|  | ||||
|             if info: | ||||
|                 text += "\n\n" | ||||
|                 text += info | ||||
|  | ||||
|             defvalue = ArgSpec.defaults.get(key) | ||||
|  | ||||
|             if defvalue is not None: | ||||
|                 if isinstance(defvalue, str): | ||||
|                     if defvalue == "": | ||||
|                         defvalue = "[Empty string]" | ||||
|                     elif defvalue.strip() == "": | ||||
|                         spaces = defvalue.count(" ") | ||||
|                         ds = Utils.singular_or_plural(spaces, "space", "spaces") | ||||
|                         defvalue = f"[{spaces} {ds}]" | ||||
|                     else: | ||||
|                         defvalue = f'"{defvalue}"' | ||||
|  | ||||
|                 text += "\n\n" | ||||
|                 text += f"Default: {defvalue}" | ||||
|  | ||||
|             choices = arg.get("choices", []) | ||||
|  | ||||
|             if choices: | ||||
|                 text += "\n\n" | ||||
|                 text += "Choices: " | ||||
|  | ||||
|                 choicestr = [ | ||||
|                     f'"{choice}"' if isinstance(choice, str) else choice | ||||
|                     for choice in choices | ||||
|                 ] | ||||
|  | ||||
|                 text += ", ".join(choicestr) | ||||
|  | ||||
|             action = arg.get("action", "") | ||||
|  | ||||
|             if action: | ||||
|                 text += "\n\n" | ||||
|                 text += f"Action: {action}" | ||||
|  | ||||
|             argtype = arg.get("type", "") | ||||
|  | ||||
|             if argtype: | ||||
|                 text += "\n\n" | ||||
|                 text += f"Type: {argtype.__name__}" | ||||
|  | ||||
|         text += "\n" | ||||
|         return text.lstrip() | ||||
|  | ||||
|  | ||||
| class ArgParser: | ||||
|     parser: argparse.ArgumentParser | ||||
|     args: argparse.Namespace | ||||
|  | ||||
|     @staticmethod | ||||
|     def prepare(title: str, argdefs: dict[str, Any]) -> None: | ||||
|         parser = argparse.ArgumentParser(description=title) | ||||
|         argdefs["string_arg"] = {"nargs": "*"} | ||||
|  | ||||
|         for key in argdefs: | ||||
|             item = argdefs[key] | ||||
|  | ||||
|             if key == "string_arg": | ||||
|                 name = key | ||||
|             else: | ||||
|                 name = ArgParser.under_to_dash(key) | ||||
|                 name = f"--{name}" | ||||
|  | ||||
|             tail = {key: value for key, value in item.items() if value is not None} | ||||
|             parser.add_argument(name, **tail) | ||||
|  | ||||
|         ArgParser.parser = parser | ||||
|         ArgParser.args = parser.parse_args() | ||||
|  | ||||
|     @staticmethod | ||||
|     def string_arg() -> str: | ||||
|         return " ".join(ArgParser.args.string_arg) | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_value( | ||||
|         attr: str, key: str | None = None, no_strip: bool = False, path: bool = False | ||||
|     ) -> None: | ||||
|         value = getattr(ArgParser.args, attr) | ||||
|  | ||||
|         if value is not None: | ||||
|             if not no_strip: | ||||
|                 if isinstance(value, str): | ||||
|                     value = value.strip() | ||||
|  | ||||
|             obj = key if key else attr | ||||
|  | ||||
|             if path: | ||||
|                 value = Path(value) | ||||
|  | ||||
|             ArgParser.set(obj, value) | ||||
|  | ||||
|     @staticmethod | ||||
|     def set(attr: str, value: Any) -> None: | ||||
|         setattr(Args, attr, value) | ||||
|  | ||||
|     @staticmethod | ||||
|     def under_to_dash(s: str) -> str: | ||||
|         return s.replace("_", "-") | ||||
							
								
								
									
										186
									
								
								cromulant/argspec.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,186 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Any | ||||
|  | ||||
| from .config import Config | ||||
|  | ||||
|  | ||||
| class DuplicateArgumentError(Exception): | ||||
|     def __init__(self, key: str) -> None: | ||||
|         self.message = f"Duplicate argument: {key}" | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return self.message | ||||
|  | ||||
|  | ||||
| class MissingInfoError(Exception): | ||||
|     def __init__(self, key: str) -> None: | ||||
|         self.message = f"Missing info for argument: {key}" | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return self.message | ||||
|  | ||||
|  | ||||
| class DuplicateInfoError(Exception): | ||||
|     def __init__(self, key: str) -> None: | ||||
|         self.message = f"Duplicate info for argument: {key}" | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return self.message | ||||
|  | ||||
|  | ||||
| class ArgSpec: | ||||
|     vinfo: str | ||||
|     defaults: dict[str, Any] | ||||
|     arguments: dict[str, Any] | ||||
|     infos: list[str] | ||||
|  | ||||
|     @staticmethod | ||||
|     def prepare() -> None: | ||||
|         ArgSpec.vinfo = f"{Config.title} {Config.version}" | ||||
|         ArgSpec.defaults = {} | ||||
|         ArgSpec.arguments = {} | ||||
|         ArgSpec.infos = [] | ||||
|         ArgSpec.add_arguments() | ||||
|  | ||||
|     @staticmethod | ||||
|     def add_argument(key: str, info: str, **kwargs: Any) -> None: | ||||
|         if key in ArgSpec.arguments: | ||||
|             raise DuplicateArgumentError(key) | ||||
|  | ||||
|         if not info: | ||||
|             raise MissingInfoError(key) | ||||
|  | ||||
|         if info in ArgSpec.infos: | ||||
|             raise DuplicateInfoError(key) | ||||
|  | ||||
|         ArgSpec.arguments[key] = { | ||||
|             "help": info, | ||||
|             **kwargs, | ||||
|         } | ||||
|  | ||||
|         ArgSpec.infos.append(info) | ||||
|  | ||||
|     @staticmethod | ||||
|     def add_arguments() -> None: | ||||
|         ArgSpec.add_argument( | ||||
|             "version", | ||||
|             action="version", | ||||
|             info="Check the version of the program", | ||||
|             version=ArgSpec.vinfo, | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "names", | ||||
|             type=str, | ||||
|             info="Path to a JSON file with a list of names. Use these instead of the default ones", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "ants", | ||||
|             type=str, | ||||
|             info="Path to a JSON file with ants data. Use this instead of the default one", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "no_images", | ||||
|             action="store_false", | ||||
|             info="Don't show the images on the left", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "no_header", | ||||
|             action="store_false", | ||||
|             info="Don't show the header controls", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "no_footer", | ||||
|             action="store_false", | ||||
|             info="Don't show the footer controls", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "no_intro", | ||||
|             action="store_false", | ||||
|             info="Don't show the intro message", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "title", | ||||
|             type=str, | ||||
|             info="Custom title for the window", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "width", | ||||
|             type=int, | ||||
|             info="The width of the window in pixels", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "height", | ||||
|             type=int, | ||||
|             info="The height of the window in pixels", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "program", | ||||
|             type=str, | ||||
|             info="The internal name of the program", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "speed", | ||||
|             type=str, | ||||
|             choices=["fast", "normal", "slow", "paused"], | ||||
|             info="Use this update speed", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "clean", | ||||
|             action="store_true", | ||||
|             info="Start with clean ants data", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "fast_seconds", | ||||
|             type=int, | ||||
|             info="The number of seconds between fast updates", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "normal_minutes", | ||||
|             type=float, | ||||
|             info="The number of minutes between normal updates", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "slow_minutes", | ||||
|             type=float, | ||||
|             info="The number of minutes between slow updates", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "argdoc", | ||||
|             action="store_true", | ||||
|             info="Make the arguments document and exit", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "score", | ||||
|             action="store_true", | ||||
|             info="Show the score on triumph or hits instead of the total of each", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "mono", | ||||
|             action="store_true", | ||||
|             info="Use a monospace font", | ||||
|         ) | ||||
|  | ||||
|         ArgSpec.add_argument( | ||||
|             "no_fade", | ||||
|             action="store_false", | ||||
|             info="Don't apply a fade-in effect on new updates", | ||||
|         ) | ||||
							
								
								
									
										
											BIN
										
									
								
								cromulant/audio/March of the Cyber Ants.mp3
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										105
									
								
								cromulant/config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,105 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from pathlib import Path | ||||
|  | ||||
| import appdirs  # type: ignore | ||||
|  | ||||
|  | ||||
| class Config: | ||||
|     program: str | ||||
|     title: str | ||||
|     version: str | ||||
|     width: int = 820 | ||||
|     height: int = 900 | ||||
|     here: Path | ||||
|     ants_json: Path | ||||
|     icon_path: Path | ||||
|     status_image_path: Path | ||||
|     hatched_image_path: Path | ||||
|     top_image_path: Path | ||||
|     terminated_image_path: Path | ||||
|     names_json: Path | ||||
|     background_color: str = "rgb(44, 44, 44)" | ||||
|     text_color: str = "#ffffff" | ||||
|     image_size: int = 80 | ||||
|     space_1: int = 18 | ||||
|     max_updates: int = 300 | ||||
|     fast_seconds: int = 5 | ||||
|     normal_minutes: float = 1 | ||||
|     slow_minutes: float = 5 | ||||
|     font_size: int = 20 | ||||
|     info_separator: str = "  -  " | ||||
|     font_path: Path | ||||
|     emoji_font_path: Path | ||||
|     mono_font_path: Path | ||||
|     triumph_color: tuple[int, int, int] = (255, 255, 0) | ||||
|     hit_color: tuple[int, int, int] = (255, 0, 77) | ||||
|     triumph_icon: str = "😀" | ||||
|     hit_icon: str = "🎃" | ||||
|     triumph_message: str = "Scored a triumph" | ||||
|     hit_message: str = "Took a hit" | ||||
|     song_path: Path | ||||
|     logo_path: Path | ||||
|     alt_background_color: str = "rgb(33, 33, 33)" | ||||
|     alt_text_color: str = "white" | ||||
|     alt_hover_background_color: str = "rgb(51, 51, 51)" | ||||
|     alt_hover_text_color: str = "white" | ||||
|     alt_border_color: str = "rgb(88, 88, 88)" | ||||
|     message_box_button_hover_background_color: str = "rgb(66, 66, 66)" | ||||
|     message_box_button_hover_text_color: str = "white" | ||||
|     scrollbar_handle_color: str = "rgb(69, 69, 69)" | ||||
|     input_background_color: str = "rgb(111, 111, 111)" | ||||
|     input_text_color: str = "black" | ||||
|     input_border_color: str = "rgb(120, 120, 120)" | ||||
|     input_caret_color: str = "rgb(18, 18, 18)" | ||||
|     settings_json: Path | ||||
|     countries_json: Path | ||||
|     filter_debouncer_delay: int = 200 | ||||
|     default_population: int = 100 | ||||
|     merge_goal: int = 10 | ||||
|     manifest_path: Path | ||||
|     manifest: dict[str, str] | ||||
|     icon_on: str = "✅" | ||||
|     icon_off: str = "❌" | ||||
|     ant: str = "🐜" | ||||
|     arguments_path: Path | ||||
|     fade_duration: int = 500 | ||||
|  | ||||
|     @staticmethod | ||||
|     def prepare() -> None: | ||||
|         from .storage import Storage | ||||
|  | ||||
|         Config.here = Path(__file__).parent | ||||
|         Config.manifest_path = Config.here / "manifest.json" | ||||
|         Config.manifest = Storage.get_manifest() | ||||
|         Config.title = Config.manifest["title"] | ||||
|         Config.program = Config.manifest["program"] | ||||
|         Config.version = Config.manifest["version"] | ||||
|  | ||||
|         Config.ants_json = Path(appdirs.user_data_dir()) / Config.program / "ants.json" | ||||
|  | ||||
|         if not Config.ants_json.exists(): | ||||
|             Config.ants_json.parent.mkdir(parents=True, exist_ok=True) | ||||
|             Config.ants_json.write_text("[]") | ||||
|  | ||||
|         Config.settings_json = ( | ||||
|             Path(appdirs.user_config_dir()) / Config.program / "settings.json" | ||||
|         ) | ||||
|  | ||||
|         if not Config.settings_json.exists(): | ||||
|             Config.settings_json.parent.mkdir(parents=True, exist_ok=True) | ||||
|             Config.settings_json.write_text("{}") | ||||
|  | ||||
|         Config.names_json = Config.here / "data" / "names.json" | ||||
|         Config.countries_json = Config.here / "data" / "countries.json" | ||||
|         Config.icon_path = Config.here / "img" / "icon.jpg" | ||||
|         Config.status_image_path = Config.here / "img" / "status.jpg" | ||||
|         Config.hatched_image_path = Config.here / "img" / "hatched.jpg" | ||||
|         Config.top_image_path = Config.here / "img" / "top.jpg" | ||||
|         Config.terminated_image_path = Config.here / "img" / "terminated.jpg" | ||||
|         Config.font_path = Config.here / "fonts" / "NotoSans-Regular.ttf" | ||||
|         Config.emoji_font_path = Config.here / "fonts" / "NotoEmoji-Regular.ttf" | ||||
|         Config.mono_font_path = Config.here / "fonts" / "NotoSansMono-Regular.ttf" | ||||
|         Config.song_path = Config.here / "audio" / "March of the Cyber Ants.mp3" | ||||
|         Config.logo_path = Config.here / "img" / "logo_3.jpg" | ||||
|         Config.arguments_path = Config.here / ".." / "arguments.md" | ||||
							
								
								
									
										250
									
								
								cromulant/data/countries.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,250 @@ | ||||
| [ | ||||
|     "Afghanistan", | ||||
|     "Albania", | ||||
|     "Algeria", | ||||
|     "American Samoa", | ||||
|     "Andorra", | ||||
|     "Angola", | ||||
|     "Anguilla", | ||||
|     "Antarctica", | ||||
|     "Antigua and Barbuda", | ||||
|     "Argentina", | ||||
|     "Armenia", | ||||
|     "Aruba", | ||||
|     "Australia", | ||||
|     "Austria", | ||||
|     "Azerbaijan", | ||||
|     "Bahamas", | ||||
|     "Bahrain", | ||||
|     "Bangladesh", | ||||
|     "Barbados", | ||||
|     "Belarus", | ||||
|     "Belgium", | ||||
|     "Belize", | ||||
|     "Benin", | ||||
|     "Bermuda", | ||||
|     "Bhutan", | ||||
|     "Bolivia", | ||||
|     "Bosnia and Herzegovina", | ||||
|     "Botswana", | ||||
|     "Bouvet Island", | ||||
|     "Brazil", | ||||
|     "British Indian Ocean Territory", | ||||
|     "Brunei", | ||||
|     "Bulgaria", | ||||
|     "Burkina Faso", | ||||
|     "Burundi", | ||||
|     "Cambodia", | ||||
|     "Cameroon", | ||||
|     "Canada", | ||||
|     "Cape Verde", | ||||
|     "Cayman Islands", | ||||
|     "Central African Republic", | ||||
|     "Chad", | ||||
|     "Chile", | ||||
|     "China", | ||||
|     "Christmas Island", | ||||
|     "Cocos (Keeling) Islands", | ||||
|     "Colombia", | ||||
|     "Comoros", | ||||
|     "Congo", | ||||
|     "The Democratic Republic of Congo", | ||||
|     "Cook Islands", | ||||
|     "Costa Rica", | ||||
|     "Ivory Coast", | ||||
|     "Croatia", | ||||
|     "Cuba", | ||||
|     "Cyprus", | ||||
|     "Czech Republic", | ||||
|     "Denmark", | ||||
|     "Djibouti", | ||||
|     "Dominica", | ||||
|     "Dominican Republic", | ||||
|     "East Timor", | ||||
|     "Ecuador", | ||||
|     "Egypt", | ||||
|     "England", | ||||
|     "El Salvador", | ||||
|     "Equatorial Guinea", | ||||
|     "Eritrea", | ||||
|     "Estonia", | ||||
|     "Eswatini", | ||||
|     "Ethiopia", | ||||
|     "Falkland Islands", | ||||
|     "Faroe Islands", | ||||
|     "Fiji Islands", | ||||
|     "Finland", | ||||
|     "France", | ||||
|     "French Guiana", | ||||
|     "French Polynesia", | ||||
|     "French Southern territories", | ||||
|     "Gabon", | ||||
|     "Gambia", | ||||
|     "Georgia", | ||||
|     "Germany", | ||||
|     "Ghana", | ||||
|     "Gibraltar", | ||||
|     "Greece", | ||||
|     "Greenland", | ||||
|     "Grenada", | ||||
|     "Guadeloupe", | ||||
|     "Guam", | ||||
|     "Guatemala", | ||||
|     "Guernsey", | ||||
|     "Guinea", | ||||
|     "Guinea-Bissau", | ||||
|     "Guyana", | ||||
|     "Haiti", | ||||
|     "Heard Island and McDonald Islands", | ||||
|     "Holy See (Vatican City State)", | ||||
|     "Honduras", | ||||
|     "Hong Kong", | ||||
|     "Hungary", | ||||
|     "Iceland", | ||||
|     "India", | ||||
|     "Indonesia", | ||||
|     "Iran", | ||||
|     "Iraq", | ||||
|     "Ireland", | ||||
|     "Israel", | ||||
|     "Isle of Man", | ||||
|     "Italy", | ||||
|     "Jamaica", | ||||
|     "Japan", | ||||
|     "Jersey", | ||||
|     "Jordan", | ||||
|     "Kazakhstan", | ||||
|     "Kenya", | ||||
|     "Kiribati", | ||||
|     "Kuwait", | ||||
|     "Kyrgyzstan", | ||||
|     "Laos", | ||||
|     "Latvia", | ||||
|     "Lebanon", | ||||
|     "Lesotho", | ||||
|     "Liberia", | ||||
|     "Libya", | ||||
|     "Liechtenstein", | ||||
|     "Lithuania", | ||||
|     "Luxembourg", | ||||
|     "Macao", | ||||
|     "North Macedonia", | ||||
|     "Madagascar", | ||||
|     "Malawi", | ||||
|     "Malaysia", | ||||
|     "Maldives", | ||||
|     "Mali", | ||||
|     "Malta", | ||||
|     "Marshall Islands", | ||||
|     "Martinique", | ||||
|     "Mauritania", | ||||
|     "Mauritius", | ||||
|     "Mayotte", | ||||
|     "Mexico", | ||||
|     "Micronesia, Federated States of", | ||||
|     "Moldova", | ||||
|     "Monaco", | ||||
|     "Mongolia", | ||||
|     "Montserrat", | ||||
|     "Montenegro", | ||||
|     "Morocco", | ||||
|     "Mozambique", | ||||
|     "Myanmar", | ||||
|     "Namibia", | ||||
|     "Nauru", | ||||
|     "Nepal", | ||||
|     "Netherlands", | ||||
|     "Netherlands Antilles", | ||||
|     "New Caledonia", | ||||
|     "New Zealand", | ||||
|     "Nicaragua", | ||||
|     "Niger", | ||||
|     "Nigeria", | ||||
|     "Niue", | ||||
|     "Norfolk Island", | ||||
|     "North Korea", | ||||
|     "Northern Ireland", | ||||
|     "Northern Mariana Islands", | ||||
|     "Norway", | ||||
|     "Oman", | ||||
|     "Pakistan", | ||||
|     "Palau", | ||||
|     "Palestine", | ||||
|     "Panama", | ||||
|     "Papua New Guinea", | ||||
|     "Paraguay", | ||||
|     "Peru", | ||||
|     "Philippines", | ||||
|     "Pitcairn", | ||||
|     "Poland", | ||||
|     "Portugal", | ||||
|     "Puerto Rico", | ||||
|     "Qatar", | ||||
|     "Reunion", | ||||
|     "Romania", | ||||
|     "Russia", | ||||
|     "Rwanda", | ||||
|     "Saint Helena", | ||||
|     "Saint Kitts and Nevis", | ||||
|     "Saint Lucia", | ||||
|     "Saint Pierre and Miquelon", | ||||
|     "Saint Vincent and the Grenadines", | ||||
|     "Samoa", | ||||
|     "San Marino", | ||||
|     "Sao Tome and Principe", | ||||
|     "Saudi Arabia", | ||||
|     "Scotland", | ||||
|     "Senegal", | ||||
|     "Serbia", | ||||
|     "Seychelles", | ||||
|     "Sierra Leone", | ||||
|     "Singapore", | ||||
|     "Slovakia", | ||||
|     "Slovenia", | ||||
|     "Solomon Islands", | ||||
|     "Somalia", | ||||
|     "South Africa", | ||||
|     "South Georgia and the South Sandwich Islands", | ||||
|     "South Korea", | ||||
|     "South Sudan", | ||||
|     "Spain", | ||||
|     "Sri Lanka", | ||||
|     "Sudan", | ||||
|     "Suriname", | ||||
|     "Svalbard and Jan Mayen", | ||||
|     "Sweden", | ||||
|     "Switzerland", | ||||
|     "Syria", | ||||
|     "Tajikistan", | ||||
|     "Tanzania", | ||||
|     "Thailand", | ||||
|     "Timor-Leste", | ||||
|     "Togo", | ||||
|     "Tokelau", | ||||
|     "Tonga", | ||||
|     "Trinidad and Tobago", | ||||
|     "Tunisia", | ||||
|     "Turkey", | ||||
|     "Turkmenistan", | ||||
|     "Turks and Caicos Islands", | ||||
|     "Tuvalu", | ||||
|     "Uganda", | ||||
|     "Ukraine", | ||||
|     "United Arab Emirates", | ||||
|     "United Kingdom", | ||||
|     "United States", | ||||
|     "United States Minor Outlying Islands", | ||||
|     "Uruguay", | ||||
|     "Uzbekistan", | ||||
|     "Vanuatu", | ||||
|     "Venezuela", | ||||
|     "Vietnam", | ||||
|     "Virgin Islands, British", | ||||
|     "Virgin Islands, U.S.", | ||||
|     "Wales", | ||||
|     "Wallis and Futuna", | ||||
|     "Western Sahara", | ||||
|     "Yemen", | ||||
|     "Zambia", | ||||
|     "Zimbabwe" | ||||
| ] | ||||
							
								
								
									
										1002
									
								
								cromulant/data/names.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										99
									
								
								cromulant/filter.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,99 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from PySide6.QtWidgets import QWidget  # type: ignore | ||||
| from PySide6.QtGui import QKeyEvent  # type: ignore | ||||
| from PySide6.QtCore import QTimer  # type: ignore | ||||
|  | ||||
| from .config import Config | ||||
| from .window import Window | ||||
|  | ||||
|  | ||||
| class Filter: | ||||
|     debouncer: QTimer | ||||
|  | ||||
|     @staticmethod | ||||
|     def prepare() -> None: | ||||
|         Filter.debouncer = QTimer() | ||||
|         Filter.debouncer.setSingleShot(True) | ||||
|         Filter.debouncer.setInterval(Config.filter_debouncer_delay) | ||||
|         Filter.debouncer.timeout.connect(Filter.do_filter) | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_value() -> str: | ||||
|         return str(Window.filter.text()).lower().strip() | ||||
|  | ||||
|     @staticmethod | ||||
|     def set_value(value: str) -> None: | ||||
|         Window.filter.setText(value) | ||||
|         Filter.do_filter() | ||||
|  | ||||
|     @staticmethod | ||||
|     def clear() -> None: | ||||
|         Window.filter.clear() | ||||
|         Filter.do_filter() | ||||
|  | ||||
|     @staticmethod | ||||
|     def filter(event: QKeyEvent | None = None) -> None: | ||||
|         Filter.debouncer.stop() | ||||
|         Filter.debouncer.start() | ||||
|  | ||||
|     @staticmethod | ||||
|     def do_filter() -> None: | ||||
|         Filter.debouncer.stop() | ||||
|         value = Filter.get_value() | ||||
|  | ||||
|         for i in range(Window.view.count()): | ||||
|             item = Window.view.itemAt(i) | ||||
|             text = Filter.get_text(item) | ||||
|             hide = True | ||||
|  | ||||
|             for txt in text: | ||||
|                 if value in txt: | ||||
|                     hide = False | ||||
|                     break | ||||
|  | ||||
|             if hide: | ||||
|                 item.widget().hide() | ||||
|             else: | ||||
|                 item.widget().show() | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_text(item: QWidget) -> list[str]: | ||||
|         text = [] | ||||
|         layout = item.widget().layout() | ||||
|  | ||||
|         for i in range(layout.count()): | ||||
|             widget = layout.itemAt(i).widget() | ||||
|  | ||||
|             if not widget: | ||||
|                 continue | ||||
|  | ||||
|             name = widget.objectName() | ||||
|  | ||||
|             if name != "view_right": | ||||
|                 continue | ||||
|  | ||||
|             layout2 = widget.layout() | ||||
|  | ||||
|             for j in range(layout2.count()): | ||||
|                 wid = layout2.itemAt(j).widget() | ||||
|  | ||||
|                 if not wid: | ||||
|                     continue | ||||
|  | ||||
|                 name = wid.objectName() | ||||
|  | ||||
|                 if not name: | ||||
|                     continue | ||||
|  | ||||
|                 if (name == "view_title") or (name == "view_message"): | ||||
|                     text.append(wid.text().lower()) | ||||
|  | ||||
|         return text | ||||
|  | ||||
|     @staticmethod | ||||
|     def check() -> None: | ||||
|         value = Filter.get_value() | ||||
|  | ||||
|         if value: | ||||
|             Filter.filter() | ||||
							
								
								
									
										
											BIN
										
									
								
								cromulant/fonts/NotoEmoji-Regular.ttf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								cromulant/fonts/NotoSans-Regular.ttf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								cromulant/fonts/NotoSansMono-Regular.ttf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										605
									
								
								cromulant/game.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,605 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import random | ||||
| from typing import Any, ClassVar | ||||
|  | ||||
| from PySide6.QtWidgets import QHBoxLayout  # type: ignore | ||||
| from PySide6.QtWidgets import QVBoxLayout | ||||
| from PySide6.QtWidgets import QLabel | ||||
| from PySide6.QtWidgets import QWidget | ||||
| from PySide6.QtWidgets import QFrame | ||||
| from PySide6.QtWidgets import QMenu | ||||
| from PySide6.QtWidgets import QDialog | ||||
| from PySide6.QtWidgets import QGraphicsOpacityEffect | ||||
| from PySide6.QtGui import QCursor  # type: ignore | ||||
| from PySide6.QtGui import QMouseEvent | ||||
| from PySide6.QtGui import QPixmap | ||||
| from PySide6.QtGui import QAction | ||||
| from PySide6.QtCore import QPropertyAnimation  # type: ignore | ||||
| from PySide6.QtCore import QEasingCurve | ||||
| from PySide6.QtCore import QSize | ||||
| from PySide6.QtCore import QTimer | ||||
| from PySide6.QtCore import Qt | ||||
|  | ||||
| from .config import Config | ||||
| from .args import Args | ||||
| from .utils import Utils | ||||
| from .ants import Ant | ||||
| from .ants import Ants | ||||
| from .window import Window | ||||
| from .window import RestartDialog | ||||
| from .settings import Settings | ||||
|  | ||||
|  | ||||
| class Method: | ||||
|     merge = "merge" | ||||
|     triumph = "triumph" | ||||
|     hit = "hit" | ||||
|     travel = "travel" | ||||
|     think = "think" | ||||
|     words = "words" | ||||
|  | ||||
|  | ||||
| class Opt: | ||||
|     value = 0 | ||||
|  | ||||
|     def __init__(self, weight: int, method: str) -> None: | ||||
|         self.value = Opt.value | ||||
|         self.weight = weight | ||||
|         self.method = method | ||||
|  | ||||
|         Opt.value += 1 | ||||
|  | ||||
|  | ||||
| class Opts: | ||||
|     merge = Opt(1, Method.merge) | ||||
|     triumph = Opt(2, Method.triumph) | ||||
|     hit = Opt(2, Method.hit) | ||||
|     travel = Opt(2, Method.travel) | ||||
|     think = Opt(2, Method.think) | ||||
|     words = Opt(4, Method.words) | ||||
|  | ||||
|     @staticmethod | ||||
|     def opts_score() -> list[Opt]: | ||||
|         return [Opts.triumph, Opts.hit] | ||||
|  | ||||
|     @staticmethod | ||||
|     def opts_travel() -> list[Opt]: | ||||
|         return [Opts.travel] | ||||
|  | ||||
|     @staticmethod | ||||
|     def opts_think() -> list[Opt]: | ||||
|         return [Opts.think] | ||||
|  | ||||
|     @staticmethod | ||||
|     def opts_words() -> list[Opt]: | ||||
|         return [Opts.words] | ||||
|  | ||||
|  | ||||
| class Game: | ||||
|     timer: QTimer | ||||
|     playing_song: bool = False | ||||
|     merge_charge: int = 0 | ||||
|     speed: str = "paused" | ||||
|     animations: ClassVar[list[QPropertyAnimation]] = [] | ||||
|     started: bool = False | ||||
|  | ||||
|     @staticmethod | ||||
|     def prepare() -> None: | ||||
|         Game.timer = QTimer() | ||||
|         Game.timer.timeout.connect(Game.get_status) | ||||
|  | ||||
|         Game.fill() | ||||
|         Game.info() | ||||
|  | ||||
|         if Args.intro: | ||||
|             Game.intro() | ||||
|  | ||||
|     @staticmethod | ||||
|     def update(ant: Ant) -> None: | ||||
|         root = QWidget() | ||||
|         container = QHBoxLayout() | ||||
|         root.setContentsMargins(0, 0, 0, 0) | ||||
|         container.setContentsMargins(0, 0, 0, 0) | ||||
|  | ||||
|         if Args.images: | ||||
|             image_label = Game.get_image(ant) | ||||
|             container.addWidget(image_label) | ||||
|  | ||||
|         right_container = Game.make_right_container(ant) | ||||
|         container.addWidget(right_container) | ||||
|  | ||||
|         container.addSpacing(Config.space_1) | ||||
|         root.setLayout(container) | ||||
|         Game.add_item(root) | ||||
|  | ||||
|     @staticmethod | ||||
|     def message(text: str) -> None: | ||||
|         root = QWidget() | ||||
|         root.setContentsMargins(0, 10, 0, 10) | ||||
|  | ||||
|         container = QHBoxLayout() | ||||
|         container.setAlignment(Qt.AlignCenter) | ||||
|  | ||||
|         left_line = QFrame() | ||||
|         left_line.setFrameShape(QFrame.HLine) | ||||
|         left_line.setFrameShadow(QFrame.Sunken) | ||||
|         left_line.setObjectName("horizontal_line") | ||||
|         left_line.setFixedHeight(2) | ||||
|         Window.expand_2(left_line) | ||||
|  | ||||
|         label = QLabel(text) | ||||
|         label.setTextInteractionFlags(Qt.TextSelectableByMouse) | ||||
|  | ||||
|         right_line = QFrame() | ||||
|         right_line.setFrameShape(QFrame.HLine) | ||||
|         right_line.setFrameShadow(QFrame.Sunken) | ||||
|         right_line.setObjectName("horizontal_line") | ||||
|         right_line.setFixedHeight(2) | ||||
|         Window.expand_2(right_line) | ||||
|  | ||||
|         container.addWidget(left_line) | ||||
|         container.addWidget(label) | ||||
|         container.addWidget(right_line) | ||||
|  | ||||
|         container.setSpacing(Config.space_1 * 2) | ||||
|         root.setLayout(container) | ||||
|         Game.add_item(root) | ||||
|  | ||||
|     @staticmethod | ||||
|     def add_item(item: QWidget) -> None: | ||||
|         from .filter import Filter | ||||
|  | ||||
|         animation: QPropertyAnimation | None = None | ||||
|  | ||||
|         if Game.started and Args.fade: | ||||
|             animation = Game.add_fade(item) | ||||
|  | ||||
|         Window.view.insertWidget(0, item) | ||||
|  | ||||
|         if animation: | ||||
|             animation.start() | ||||
|  | ||||
|         while Window.view.count() > Config.max_updates: | ||||
|             item = Window.view.takeAt(Window.view.count() - 1) | ||||
|  | ||||
|             if item.widget(): | ||||
|                 item.widget().deleteLater() | ||||
|             elif item.layout(): | ||||
|                 Window.delete_layout(item.layout()) | ||||
|  | ||||
|         Filter.check() | ||||
|  | ||||
|     @staticmethod | ||||
|     def make_right_container(ant: Ant) -> QWidget: | ||||
|         if ant.method == "hatched": | ||||
|             title = "Hatched" | ||||
|             message = f"{ant.name} is born" | ||||
|         elif ant.method == "terminated": | ||||
|             title = "Terminated" | ||||
|             message = f"{ant.name} is gone" | ||||
|         else: | ||||
|             title = ant.name | ||||
|             message = ant.get_status() | ||||
|  | ||||
|         root = QWidget() | ||||
|         root.setObjectName("view_right") | ||||
|         container = QVBoxLayout() | ||||
|         container.setAlignment(Qt.AlignTop) | ||||
|  | ||||
|         title_label = QLabel(title) | ||||
|         title_label.setTextInteractionFlags(Qt.TextSelectableByMouse) | ||||
|         title_label.setStyleSheet("font-weight: bold;") | ||||
|         title_label.setWordWrap(True) | ||||
|         title_label.setObjectName("view_title") | ||||
|         Window.expand(title_label) | ||||
|  | ||||
|         message_label = QLabel(message) | ||||
|         message_label.setTextInteractionFlags(Qt.TextSelectableByMouse) | ||||
|         message_label.setWordWrap(True) | ||||
|         message_label.setObjectName("view_message") | ||||
|         Window.expand(message_label) | ||||
|  | ||||
|         container.addWidget(title_label) | ||||
|         container.addWidget(message_label) | ||||
|         root.setLayout(container) | ||||
|  | ||||
|         return root | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_image(ant: Ant) -> QLabel: | ||||
|         if ant.method == "hatched": | ||||
|             path = Config.hatched_image_path | ||||
|         elif ant.method == "terminated": | ||||
|             path = Config.terminated_image_path | ||||
|         elif ant == Ants.top: | ||||
|             path = Config.top_image_path | ||||
|         else: | ||||
|             path = Config.status_image_path | ||||
|  | ||||
|         if ant.method == "triumph": | ||||
|             color = Config.triumph_color | ||||
|         elif ant.method == "hit": | ||||
|             color = Config.hit_color | ||||
|         else: | ||||
|             color = None | ||||
|  | ||||
|         tooltip = ant.tooltip() | ||||
|         image_label = QLabel() | ||||
|         image_label.setObjectName("view_image") | ||||
|         pixmap = QPixmap(str(path)) | ||||
|  | ||||
|         scaled_pixmap = pixmap.scaled( | ||||
|             Config.image_size, | ||||
|             pixmap.height(), | ||||
|             Qt.KeepAspectRatio, | ||||
|             Qt.SmoothTransformation, | ||||
|         ) | ||||
|  | ||||
|         image_label.setPixmap(scaled_pixmap) | ||||
|         adjusted_size = scaled_pixmap.size() + QSize(4, 4) | ||||
|         image_label.setFixedSize(adjusted_size) | ||||
|  | ||||
|         if color: | ||||
|             rgb = Utils.get_rgb(color) | ||||
|  | ||||
|             style = f""" | ||||
|             QLabel#view_image {{ | ||||
|                 border: 2px solid {rgb}; | ||||
|             }} | ||||
|             """ | ||||
|  | ||||
|             image_label.setStyleSheet(style) | ||||
|  | ||||
|         if tooltip: | ||||
|             image_label.setToolTip(tooltip) | ||||
|  | ||||
|         image_label.mousePressEvent = lambda event: Game.image_action(event, ant) | ||||
|         return image_label | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_status() -> None: | ||||
|         ant = Ants.get_next() | ||||
|  | ||||
|         if not ant: | ||||
|             return | ||||
|  | ||||
|         opts: list[Opt] = [] | ||||
|  | ||||
|         if Settings.score_enabled: | ||||
|             opts.extend(Opts.opts_score()) | ||||
|  | ||||
|         if Settings.travel_enabled: | ||||
|             opts.extend(Opts.opts_travel()) | ||||
|  | ||||
|         if Settings.think_enabled: | ||||
|             opts.extend(Opts.opts_think()) | ||||
|  | ||||
|         if Settings.words_enabled: | ||||
|             opts.extend(Opts.opts_words()) | ||||
|  | ||||
|         if not opts: | ||||
|             return | ||||
|  | ||||
|         values = [opt.value for opt in opts] | ||||
|         weights = [opt.weight for opt in opts] | ||||
|  | ||||
|         if Game.merge_charge < Config.merge_goal: | ||||
|             Game.merge_charge += 1 | ||||
|  | ||||
|         if Settings.merge: | ||||
|             if Game.merge_charge >= Config.merge_goal: | ||||
|                 opt = Opts.merge | ||||
|                 values.insert(0, opt.value) | ||||
|                 weights.insert(0, opt.weight) | ||||
|  | ||||
|         value = random.choices(values, weights=weights, k=1)[0] | ||||
|  | ||||
|         if value == Opts.merge.value: | ||||
|             if Ants.merge(): | ||||
|                 Game.merge_charge = 0 | ||||
|                 return | ||||
|  | ||||
|             value = Opts.words.value | ||||
|  | ||||
|         status = "" | ||||
|         method = "" | ||||
|  | ||||
|         if value == Opts.triumph.value: | ||||
|             ant.triumph += 1 | ||||
|             method = Opts.triumph.method | ||||
|  | ||||
|         elif value == Opts.hit.value: | ||||
|             ant.hits += 1 | ||||
|             method = Opts.hit.method | ||||
|  | ||||
|         elif value == Opts.travel.value: | ||||
|             status = Utils.random_country([]) | ||||
|             method = Opts.travel.method | ||||
|  | ||||
|         elif value == Opts.think.value: | ||||
|             method = Opts.think.method | ||||
|             n = random.choices([1, 2, 3], weights=[1, 2, 2])[0] | ||||
|  | ||||
|             if n == 1: | ||||
|                 status = Utils.random_name([], Ants.get_names()) | ||||
|             elif n == 2: | ||||
|                 status = Utils.random_emoji(3) | ||||
|             elif n == 3: | ||||
|                 status = Utils.random_word(noun=True, adj=False) | ||||
|  | ||||
|         elif value == Opts.words.value: | ||||
|             method = Opts.words.method | ||||
|             n = random.randint(1, 4) | ||||
|  | ||||
|             if n == 1: | ||||
|                 status = Utils.words_1() | ||||
|             elif n == 2: | ||||
|                 status = Utils.words_2() | ||||
|             elif n == 3: | ||||
|                 status = Utils.words_3() | ||||
|             elif n == 4: | ||||
|                 status = Utils.words_4() | ||||
|  | ||||
|         else: | ||||
|             status = "???" | ||||
|             method = "unknown" | ||||
|  | ||||
|         Ants.set_status(ant, status, method) | ||||
|  | ||||
|     @staticmethod | ||||
|     def fill() -> None: | ||||
|         if not len(Ants.ants): | ||||
|             return | ||||
|  | ||||
|         ants = sorted(Ants.ants, key=lambda ant: ant.updated) | ||||
|  | ||||
|         for ant in ants: | ||||
|             Game.update(ant) | ||||
|  | ||||
|     @staticmethod | ||||
|     def start_loop() -> None: | ||||
|         Game.timer.stop() | ||||
|         speed = Settings.speed | ||||
|  | ||||
|         if speed == "fast": | ||||
|             minutes = (Args.fast_seconds or Config.fast_seconds) / 60 | ||||
|         elif speed == "normal": | ||||
|             minutes = Args.normal_minutes or Config.normal_minutes | ||||
|         elif speed == "slow": | ||||
|             minutes = Args.slow_minutes or Config.slow_minutes | ||||
|         else: | ||||
|             Game.speed = "paused" | ||||
|             return | ||||
|  | ||||
|         Game.speed = speed | ||||
|         msecs = minutes * 60 * 1000 | ||||
|  | ||||
|         if msecs < 1000: | ||||
|             msecs = 1000 | ||||
|  | ||||
|         Game.timer.setInterval(msecs) | ||||
|         Game.timer.start() | ||||
|  | ||||
|     @staticmethod | ||||
|     def update_speed() -> None: | ||||
|         speed = Window.speed.currentText().lower() | ||||
|  | ||||
|         if speed == Settings.speed: | ||||
|             return | ||||
|  | ||||
|         Settings.set_speed(speed) | ||||
|         Game.start_loop() | ||||
|  | ||||
|     @staticmethod | ||||
|     def info() -> None: | ||||
|         text = [] | ||||
|  | ||||
|         # Non-breaking space | ||||
|         nb = "\u00a0" | ||||
|  | ||||
|         if not len(Ants.ants): | ||||
|             text.append("Hatch some ants") | ||||
|         else: | ||||
|             text.append(f"Ants:{nb}{len(Ants.ants)}") | ||||
|             top = Ants.top | ||||
|  | ||||
|             if top: | ||||
|                 score = top.get_score() | ||||
|                 text.append(f"Top:{nb}{top.name} ({score})") | ||||
|  | ||||
|         Window.info.setText(Config.info_separator.join(text)) | ||||
|  | ||||
|     @staticmethod | ||||
|     def toggle_song() -> None: | ||||
|         if Game.playing_song: | ||||
|             Window.stop_audio() | ||||
|             Game.playing_song = False | ||||
|         else: | ||||
|             path = str(Config.song_path) | ||||
|  | ||||
|             def on_stop() -> None: | ||||
|                 Game.playing_song = False | ||||
|  | ||||
|             Window.play_audio(path, on_stop) | ||||
|             Game.playing_song = True | ||||
|  | ||||
|     @staticmethod | ||||
|     def restart() -> None: | ||||
|         sizes = ["25", "50", "100", "250"] | ||||
|         defindex = 0 | ||||
|  | ||||
|         for i, opt in enumerate(sizes): | ||||
|             if int(opt) == Config.default_population: | ||||
|                 defindex = i | ||||
|                 break | ||||
|  | ||||
|         size_opts = [f"{opt} ants" for opt in sizes] | ||||
|         dialog = RestartDialog(size_opts, defindex) | ||||
|         data: dict[str, Any] | None = None | ||||
|  | ||||
|         if dialog.exec() == QDialog.Accepted: | ||||
|             data = dialog.get_data() | ||||
|  | ||||
|         if not data: | ||||
|             return | ||||
|  | ||||
|         size = int(data["size"].split(" ")[0]) | ||||
|  | ||||
|         Game.started = False | ||||
|         Game.timer.stop() | ||||
|         Window.clear_view() | ||||
|         Ants.populate(size) | ||||
|         Window.to_top() | ||||
|         Game.intro() | ||||
|         Game.start_loop() | ||||
|         Game.started = True | ||||
|  | ||||
|     @staticmethod | ||||
|     def update_size() -> None: | ||||
|         pass | ||||
|  | ||||
|     @staticmethod | ||||
|     def image_action(event: QMouseEvent, ant: Ant) -> None: | ||||
|         def is_terminated() -> bool: | ||||
|             return ant.method == "terminated" | ||||
|  | ||||
|         if event.button() == Qt.LeftButton: | ||||
|             if is_terminated(): | ||||
|                 return | ||||
|  | ||||
|             Ants.terminate(ant) | ||||
|             Game.start_loop() | ||||
|         elif event.button() == Qt.MiddleButton: | ||||
|             if is_terminated(): | ||||
|                 return | ||||
|  | ||||
|             Ants.merge(ant) | ||||
|             Game.start_loop() | ||||
|         else: | ||||
|             Game.toggle_song() | ||||
|  | ||||
|     @staticmethod | ||||
|     def intro() -> None: | ||||
|         title = Config.title | ||||
|         version = Config.version | ||||
|         Game.message(f"Welcome to {title} v{version}") | ||||
|  | ||||
|     @staticmethod | ||||
|     def menu() -> None: | ||||
|         menu = QMenu(Window.root.widget()) | ||||
|  | ||||
|         style = f""" | ||||
|         QMenu::separator {{ | ||||
|             background-color: {Config.alt_border_color}; | ||||
|         }} | ||||
|         """ | ||||
|  | ||||
|         menu.setStyleSheet(style) | ||||
|         menu.setObjectName("main_menu") | ||||
|         update = QAction("Update") | ||||
|         restart = QAction("Restart") | ||||
|         enable_all = QAction("Enable All") | ||||
|         disable_all = QAction("Disable All") | ||||
|         about = QAction("About") | ||||
|  | ||||
|         def make(text: str, enabled: bool) -> QAction: | ||||
|             if enabled: | ||||
|                 icon = Config.icon_on | ||||
|                 word = "On" | ||||
|             else: | ||||
|                 icon = Config.icon_off | ||||
|                 word = "Off" | ||||
|  | ||||
|             return QAction(f"{icon} {text} {word}") | ||||
|  | ||||
|         merge = make("Merge", Settings.merge) | ||||
|         score = make("Score", Settings.score_enabled) | ||||
|         travel = make("Travel", Settings.travel_enabled) | ||||
|         think = make("Think", Settings.think_enabled) | ||||
|         words = make("Words", Settings.words_enabled) | ||||
|  | ||||
|         update.triggered.connect(Game.force_update) | ||||
|         restart.triggered.connect(Game.restart) | ||||
|         merge.triggered.connect(Settings.toggle_merge) | ||||
|         score.triggered.connect(Settings.toggle_score_enabled) | ||||
|         travel.triggered.connect(Settings.toggle_travel_enabled) | ||||
|         think.triggered.connect(Settings.toggle_think_enabled) | ||||
|         words.triggered.connect(Settings.toggle_words_enabled) | ||||
|         enable_all.triggered.connect(Settings.enable_all) | ||||
|         disable_all.triggered.connect(Settings.disable_all) | ||||
|         about.triggered.connect(Game.about) | ||||
|  | ||||
|         menu.addAction(update) | ||||
|         menu.addAction(restart) | ||||
|         menu.addSeparator() | ||||
|         menu.addAction(merge) | ||||
|         menu.addAction(score) | ||||
|         menu.addAction(travel) | ||||
|         menu.addAction(think) | ||||
|         menu.addAction(words) | ||||
|         menu.addSeparator() | ||||
|         menu.addAction(enable_all) | ||||
|         menu.addAction(disable_all) | ||||
|         menu.addSeparator() | ||||
|         menu.addAction(about) | ||||
|         menu.exec_(QCursor.pos()) | ||||
|  | ||||
|     @staticmethod | ||||
|     def force_update() -> None: | ||||
|         Game.get_status() | ||||
|         Game.start_loop() | ||||
|  | ||||
|     @staticmethod | ||||
|     def about() -> None: | ||||
|         lines = [ | ||||
|             f"{Config.title} v{Config.version}    {Config.ant}", | ||||
|             "Listen to the ants and watch them go.", | ||||
|             "Just run it and leave it open on your screen.", | ||||
|         ] | ||||
|  | ||||
|         Window.alert("\n\n".join(lines)) | ||||
|  | ||||
|     @staticmethod | ||||
|     def slowdown() -> None: | ||||
|         if Game.speed == "slow": | ||||
|             Game.change_speed("paused") | ||||
|         else: | ||||
|             Game.change_speed("slow") | ||||
|  | ||||
|     @staticmethod | ||||
|     def change_speed(speed: str) -> None: | ||||
|         Window.speed.setCurrentText(speed.capitalize()) | ||||
|  | ||||
|     @staticmethod | ||||
|     def filter_top() -> None: | ||||
|         from .filter import Filter | ||||
|  | ||||
|         value = Filter.get_value() | ||||
|         ant = Ants.top | ||||
|  | ||||
|         if not ant: | ||||
|             return | ||||
|  | ||||
|         if value == ant.name.lower(): | ||||
|             Filter.clear() | ||||
|         else: | ||||
|             Filter.set_value(ant.name) | ||||
|  | ||||
|     @staticmethod | ||||
|     def add_fade(item: QWidget) -> QPropertyAnimation: | ||||
|         opacity = QGraphicsOpacityEffect(item) | ||||
|         item.setGraphicsEffect(opacity) | ||||
|         animation = QPropertyAnimation(opacity, b"opacity") | ||||
|         animation.setDuration(Config.fade_duration) | ||||
|         animation.setStartValue(0) | ||||
|         animation.setEndValue(1) | ||||
|         animation.setEasingCurve(QEasingCurve.InOutQuad) | ||||
|  | ||||
|         def on_finish() -> None: | ||||
|             item.setGraphicsEffect(None) | ||||
|             Game.animations.remove(animation) | ||||
|  | ||||
|         animation.finished.connect(on_finish) | ||||
|         Game.animations.append(animation) | ||||
|         return animation | ||||
							
								
								
									
										
											BIN
										
									
								
								cromulant/img/change.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 306 KiB | 
							
								
								
									
										3
									
								
								cromulant/img/green.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | ||||
| #!/usr/bin/env bash | ||||
|  | ||||
| convert $1 -channel G -evaluate add 16% +channel -quality 85 -strip $1_green | ||||
							
								
								
									
										
											BIN
										
									
								
								cromulant/img/hatched.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 292 KiB | 
							
								
								
									
										
											BIN
										
									
								
								cromulant/img/icon.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 492 KiB | 
							
								
								
									
										
											BIN
										
									
								
								cromulant/img/logo_1.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 165 KiB | 
							
								
								
									
										
											BIN
										
									
								
								cromulant/img/logo_2.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 188 KiB | 
							
								
								
									
										
											BIN
										
									
								
								cromulant/img/logo_3.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 153 KiB | 
							
								
								
									
										
											BIN
										
									
								
								cromulant/img/logo_4.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 146 KiB | 
							
								
								
									
										3
									
								
								cromulant/img/red.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | ||||
| #!/usr/bin/env bash | ||||
|  | ||||
| convert $1 -channel R -evaluate add 16% +channel -quality 85 -strip $1_red | ||||
							
								
								
									
										
											BIN
										
									
								
								cromulant/img/status.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 271 KiB | 
							
								
								
									
										
											BIN
										
									
								
								cromulant/img/terminated.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 284 KiB | 
							
								
								
									
										
											BIN
										
									
								
								cromulant/img/top.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 415 KiB | 
							
								
								
									
										56
									
								
								cromulant/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,56 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import os | ||||
| import sys | ||||
| import fcntl | ||||
| import tempfile | ||||
| from pathlib import Path | ||||
|  | ||||
| from .config import Config | ||||
| from .utils import Utils | ||||
| from .ants import Ants | ||||
| from .window import Window | ||||
| from .game import Game | ||||
| from .settings import Settings | ||||
| from .filter import Filter | ||||
| from .args import Args | ||||
|  | ||||
|  | ||||
| def main() -> None: | ||||
|     Config.prepare() | ||||
|     Args.prepare() | ||||
|  | ||||
|     if Args.argdoc: | ||||
|         Args.make_argdoc() | ||||
|         sys.exit(0) | ||||
|  | ||||
|     program = Config.program | ||||
|     title = Config.title | ||||
|  | ||||
|     pid = f"{program}.pid" | ||||
|     pid_file = Path(tempfile.gettempdir(), pid) | ||||
|     fp = pid_file.open("w", encoding="utf-8") | ||||
|  | ||||
|     try: | ||||
|         fcntl.lockf(fp, fcntl.LOCK_EX | fcntl.LOCK_NB) | ||||
|     except OSError: | ||||
|         Utils.print(f"{title} is already running.") | ||||
|         sys.exit(0) | ||||
|  | ||||
|     # Create singleton | ||||
|     fp.write(str(os.getpid())) | ||||
|     fp.flush() | ||||
|  | ||||
|     Utils.prepare() | ||||
|     Window.prepare() | ||||
|     Ants.prepare() | ||||
|     Settings.prepare() | ||||
|     Filter.prepare() | ||||
|     Game.prepare() | ||||
|     Game.start_loop() | ||||
|     Game.started = True | ||||
|     Window.start() | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     main() | ||||
							
								
								
									
										8
									
								
								cromulant/manifest.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,8 @@ | ||||
| { | ||||
|     "version": "4.2.0", | ||||
|     "title": "Cromulant", | ||||
|     "program": "cromulant", | ||||
|     "author": "madprops", | ||||
|     "repo": "github.com/madprops/cromulant", | ||||
|     "description": "Toy game about ants" | ||||
| } | ||||
							
								
								
									
										47
									
								
								cromulant/ruff.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,47 @@ | ||||
| [lint] | ||||
|  | ||||
| select = [ | ||||
|     "T", | ||||
|     "Q", | ||||
|     "W", | ||||
|     "B", | ||||
|     "N", | ||||
|     "F", | ||||
|     "FA", | ||||
|     "RET", | ||||
|     "PTH", | ||||
|     "ERA", | ||||
|     "PLW", | ||||
|     "PERF", | ||||
|     "RUF", | ||||
|     "FLY", | ||||
|     "PT", | ||||
|     "PYI", | ||||
|     "PIE", | ||||
|     "ICN", | ||||
|     "UP", | ||||
|     "TRY", | ||||
|     "C4", | ||||
|     "E401", | ||||
|     "E713", | ||||
|     "E721", | ||||
|     "S101", | ||||
|     "S113", | ||||
|     "SIM103", | ||||
|     "SIM114", | ||||
|     "SIM118", | ||||
|     "SIM210", | ||||
|     "PLR5501", | ||||
|     "PLR1711", | ||||
| ] | ||||
|  | ||||
| ignore = [ | ||||
|     "W292", | ||||
|     "N802", | ||||
|     "N815", | ||||
| ] | ||||
|  | ||||
| exclude = [ | ||||
|     "pyperclip.py", | ||||
|     "tests.py", | ||||
| ] | ||||
							
								
								
									
										101
									
								
								cromulant/settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,101 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from .args import Args | ||||
| from .window import Window | ||||
| from .storage import Storage | ||||
|  | ||||
|  | ||||
| class Settings: | ||||
|     speed: str | ||||
|     mode: str | ||||
|     merge: bool | ||||
|  | ||||
|     score_enabled: bool | ||||
|     travel_enabled: bool | ||||
|     think_enabled: bool | ||||
|     words_enabled: bool | ||||
|  | ||||
|     @staticmethod | ||||
|     def prepare() -> None: | ||||
|         settings = Storage.get_settings() | ||||
|         changed = False | ||||
|  | ||||
|         if Args.speed: | ||||
|             Settings.speed = Args.speed | ||||
|             changed = True | ||||
|         else: | ||||
|             Settings.speed = settings.get("speed", "normal") | ||||
|  | ||||
|         speed = Settings.speed.capitalize() | ||||
|         Window.speed.setCurrentText(speed) | ||||
|  | ||||
|         Settings.score_enabled = settings.get("score_enabled", True) | ||||
|         Settings.travel_enabled = settings.get("travel_enabled", True) | ||||
|         Settings.think_enabled = settings.get("think_enabled", True) | ||||
|         Settings.words_enabled = settings.get("words_enabled", True) | ||||
|  | ||||
|         Settings.merge = settings.get("merge", True) | ||||
|  | ||||
|         if changed: | ||||
|             Settings.save() | ||||
|  | ||||
|     @staticmethod | ||||
|     def save() -> None: | ||||
|         settings = { | ||||
|             "speed": Settings.speed, | ||||
|             "merge": Settings.merge, | ||||
|             "score_enabled": Settings.score_enabled, | ||||
|             "travel_enabled": Settings.travel_enabled, | ||||
|             "think_enabled": Settings.think_enabled, | ||||
|             "words_enabled": Settings.words_enabled, | ||||
|         } | ||||
|  | ||||
|         Storage.save_settings(settings) | ||||
|  | ||||
|     @staticmethod | ||||
|     def set_speed(speed: str) -> None: | ||||
|         Settings.speed = speed | ||||
|         Settings.save() | ||||
|  | ||||
|     @staticmethod | ||||
|     def toggle_merge() -> None: | ||||
|         Settings.merge = not Settings.merge | ||||
|         Settings.save() | ||||
|  | ||||
|     @staticmethod | ||||
|     def toggle_score_enabled() -> None: | ||||
|         Settings.score_enabled = not Settings.score_enabled | ||||
|         Settings.save() | ||||
|  | ||||
|     @staticmethod | ||||
|     def toggle_travel_enabled() -> None: | ||||
|         Settings.travel_enabled = not Settings.travel_enabled | ||||
|         Settings.save() | ||||
|  | ||||
|     @staticmethod | ||||
|     def toggle_think_enabled() -> None: | ||||
|         Settings.think_enabled = not Settings.think_enabled | ||||
|         Settings.save() | ||||
|  | ||||
|     @staticmethod | ||||
|     def toggle_words_enabled() -> None: | ||||
|         Settings.words_enabled = not Settings.words_enabled | ||||
|         Settings.save() | ||||
|  | ||||
|     @staticmethod | ||||
|     def enable_all() -> None: | ||||
|         Settings.merge = True | ||||
|         Settings.score_enabled = True | ||||
|         Settings.travel_enabled = True | ||||
|         Settings.think_enabled = True | ||||
|         Settings.words_enabled = True | ||||
|         Settings.save() | ||||
|  | ||||
|     @staticmethod | ||||
|     def disable_all() -> None: | ||||
|         Settings.merge = False | ||||
|         Settings.score_enabled = False | ||||
|         Settings.travel_enabled = False | ||||
|         Settings.think_enabled = False | ||||
|         Settings.words_enabled = False | ||||
|         Settings.save() | ||||
							
								
								
									
										90
									
								
								cromulant/storage.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,90 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import json | ||||
| from typing import TYPE_CHECKING, Any | ||||
| from pathlib import Path | ||||
|  | ||||
| from .config import Config | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from .ants import Ant | ||||
|  | ||||
| from .args import Args | ||||
| from .utils import Utils | ||||
|  | ||||
|  | ||||
| class Storage: | ||||
|     @staticmethod | ||||
|     def get_names_path() -> Path: | ||||
|         path = Config.names_json | ||||
|  | ||||
|         if Args.names: | ||||
|             if Args.names.exists(): | ||||
|                 path = Args.names | ||||
|  | ||||
|         return path | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_ants_path() -> Path: | ||||
|         path = Config.ants_json | ||||
|  | ||||
|         if Args.ants: | ||||
|             if Args.ants.exists(): | ||||
|                 path = Args.ants | ||||
|  | ||||
|         return path | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_ants() -> Any: | ||||
|         try: | ||||
|             path = Storage.get_ants_path() | ||||
|  | ||||
|             with path.open() as file: | ||||
|                 return json.load(file) | ||||
|         except Exception as e: | ||||
|             Utils.print(str(e)) | ||||
|             return [] | ||||
|  | ||||
|     @staticmethod | ||||
|     def save_ants(ants: list[Ant]) -> None: | ||||
|         objs = [ant.to_dict() for ant in ants] | ||||
|         path = Storage.get_ants_path() | ||||
|  | ||||
|         with path.open("w") as file: | ||||
|             json.dump(objs, file) | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_names() -> Any: | ||||
|         path = Storage.get_names_path() | ||||
|  | ||||
|         with path.open() as file: | ||||
|             return json.load(file) | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_settings() -> Any: | ||||
|         try: | ||||
|             with Config.settings_json.open() as file: | ||||
|                 return json.load(file) | ||||
|         except Exception as e: | ||||
|             Utils.print(str(e)) | ||||
|             return {} | ||||
|  | ||||
|     @staticmethod | ||||
|     def save_settings(settings: dict[str, Any]) -> None: | ||||
|         with Config.settings_json.open("w") as file: | ||||
|             json.dump(settings, file) | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_countries() -> Any: | ||||
|         with Config.countries_json.open() as file: | ||||
|             return json.load(file) | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_manifest() -> Any: | ||||
|         with Config.manifest_path.open() as file: | ||||
|             return json.load(file) | ||||
|  | ||||
|     @staticmethod | ||||
|     def save_arguments(text: str) -> None: | ||||
|         with Config.arguments_path.open("w") as file: | ||||
|             file.write(text) | ||||
							
								
								
									
										221
									
								
								cromulant/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,221 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import random | ||||
| import colorsys | ||||
| import time | ||||
| from datetime import datetime | ||||
| from typing import ClassVar | ||||
|  | ||||
| from wonderwords import RandomWord, RandomSentence  # type: ignore | ||||
| from fontTools.ttLib import TTFont  # type: ignore | ||||
|  | ||||
| from .config import Config | ||||
|  | ||||
|  | ||||
| class Utils: | ||||
|     names: ClassVar[list[str]] = [] | ||||
|     countries: ClassVar[list[str]] = [] | ||||
|     rand_word: RandomWord | ||||
|     rand_sentence: RandomSentence | ||||
|     vowels = "aeiou" | ||||
|     consonants = "bcdfghjklmnpqrstvwxyz" | ||||
|  | ||||
|     @staticmethod | ||||
|     def prepare() -> None: | ||||
|         from .storage import Storage | ||||
|  | ||||
|         Utils.names = Storage.get_names() | ||||
|         Utils.countries = Storage.get_countries() | ||||
|         Utils.rand_word = RandomWord() | ||||
|         Utils.rand_sentence = RandomSentence() | ||||
|  | ||||
|     @staticmethod | ||||
|     def now() -> int: | ||||
|         return int(time.time()) | ||||
|  | ||||
|     @staticmethod | ||||
|     def singular_or_plural(num: float, singular: str, plural: str) -> str: | ||||
|         if num == 1: | ||||
|             return singular | ||||
|  | ||||
|         return plural | ||||
|  | ||||
|     @staticmethod | ||||
|     def time_ago(start_time: float, end_time: float) -> str: | ||||
|         diff = end_time - start_time | ||||
|         seconds = int(diff) | ||||
|  | ||||
|         if seconds < 60: | ||||
|             word = Utils.singular_or_plural(seconds, "second", "seconds") | ||||
|             return f"{seconds} {word} ago" | ||||
|  | ||||
|         minutes = seconds // 60 | ||||
|  | ||||
|         if minutes < 60: | ||||
|             word = Utils.singular_or_plural(minutes, "minute", "minutes") | ||||
|             return f"{minutes} {word} ago" | ||||
|  | ||||
|         hours = minutes / 60 | ||||
|  | ||||
|         if hours < 24: | ||||
|             word = Utils.singular_or_plural(hours, "hour", "hours") | ||||
|             return f"{hours:.1f} {word} ago" | ||||
|  | ||||
|         days = hours / 24 | ||||
|  | ||||
|         if days < 30: | ||||
|             word = Utils.singular_or_plural(days, "day", "days") | ||||
|             return f"{days:.1f} {word} ago" | ||||
|  | ||||
|         months = days / 30 | ||||
|  | ||||
|         if months < 12: | ||||
|             word = Utils.singular_or_plural(months, "month", "months") | ||||
|             return f"{months:.1f} {word} ago" | ||||
|  | ||||
|         years = months / 12 | ||||
|         word = Utils.singular_or_plural(years, "year", "years") | ||||
|         return f"{years:.1f} {word} ago" | ||||
|  | ||||
|     @staticmethod | ||||
|     def print(text: str) -> None: | ||||
|         print(text)  # noqa: T201 | ||||
|  | ||||
|     @staticmethod | ||||
|     def random_color(seed: str) -> tuple[int, int, int]: | ||||
|         seed_int = hash(seed) | ||||
|         random.seed(seed_int) | ||||
|  | ||||
|         h, s, l = ( | ||||
|             random.random(), | ||||
|             0.5 + random.random() / 2.0, | ||||
|             0.4 + random.random() / 5.0, | ||||
|         ) | ||||
|  | ||||
|         r, g, b = (int(256 * i) for i in colorsys.hls_to_rgb(h, l, s)) | ||||
|         return r, g, b | ||||
|  | ||||
|     @staticmethod | ||||
|     def random_name(ignore: list[str], include: list[str] | None = None) -> str: | ||||
|         names = Utils.names | ||||
|  | ||||
|         if include: | ||||
|             for name in include: | ||||
|                 if name not in names: | ||||
|                     names.append(name) | ||||
|  | ||||
|         filtered = [name for name in Utils.names if name not in ignore] | ||||
|  | ||||
|         if not filtered: | ||||
|             return Utils.make_name() | ||||
|  | ||||
|         return random.choice(filtered) | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_rgb(color: tuple[int, int, int]) -> str: | ||||
|         return f"rgb{color}" | ||||
|  | ||||
|     @staticmethod | ||||
|     def random_character(font_path: str, num: int) -> str: | ||||
|         font = TTFont(font_path) | ||||
|         cmap = font["cmap"] | ||||
|         unicode_map = cmap.getBestCmap() | ||||
|         characters = [chr(code_point) for code_point in unicode_map] | ||||
|  | ||||
|         for _ in range(10):  # Try up to 10 times | ||||
|             selected = random.sample(characters, num) | ||||
|  | ||||
|             if all((char.isprintable() and not char.isspace()) for char in selected): | ||||
|                 return " ".join(selected) | ||||
|  | ||||
|         return "" | ||||
|  | ||||
|     @staticmethod | ||||
|     def random_emoji(num: int) -> str: | ||||
|         return Utils.random_character(str(Config.emoji_font_path), num) | ||||
|  | ||||
|     @staticmethod | ||||
|     def to_date(timestamp: float) -> str: | ||||
|         dt_object = datetime.fromtimestamp(timestamp) | ||||
|         hour = dt_object.strftime("%I").lstrip("0") | ||||
|         return dt_object.strftime(f"%b %d %Y - {hour}:%M %p") | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_timeword(minutes: float) -> str: | ||||
|         if minutes < 1: | ||||
|             seconds = round(minutes * 60) | ||||
|  | ||||
|             if seconds == 1: | ||||
|                 return "1 second" | ||||
|  | ||||
|             if seconds < 60: | ||||
|                 return f"{seconds} seconds" | ||||
|  | ||||
|         if minutes == 1: | ||||
|             return "1 minute" | ||||
|  | ||||
|         return f"{round(minutes)} minutes" | ||||
|  | ||||
|     @staticmethod | ||||
|     def random_country(ignore: list[str]) -> str: | ||||
|         filtered = [country for country in Utils.countries if country not in ignore] | ||||
|         return random.choice(filtered) | ||||
|  | ||||
|     @staticmethod | ||||
|     def random_word(noun: bool = True, adj: bool = True) -> str: | ||||
|         opts = [] | ||||
|  | ||||
|         if noun: | ||||
|             opts.append("noun") | ||||
|  | ||||
|         if adj: | ||||
|             opts.append("adjective") | ||||
|  | ||||
|         if not len(opts): | ||||
|             return "" | ||||
|  | ||||
|         word = Utils.rand_word.word(include_parts_of_speech=opts, word_max_length=8) | ||||
|         return str(word) | ||||
|  | ||||
|     @staticmethod | ||||
|     def random_words(num: int = 1, noun: bool = True, adj: bool = True) -> list[str]: | ||||
|         return [Utils.random_word(noun=noun) for _ in range(num)] | ||||
|  | ||||
|     @staticmethod | ||||
|     def capitalize(word: str) -> str: | ||||
|         return word[0].upper() + word[1:] | ||||
|  | ||||
|     @staticmethod | ||||
|     def words_1() -> str: | ||||
|         return str(Utils.rand_sentence.simple_sentence()) | ||||
|  | ||||
|     @staticmethod | ||||
|     def words_2() -> str: | ||||
|         return str(Utils.rand_sentence.bare_bone_sentence()) | ||||
|  | ||||
|     @staticmethod | ||||
|     def words_3() -> str: | ||||
|         return str(Utils.rand_sentence.bare_bone_with_adjective()) | ||||
|  | ||||
|     @staticmethod | ||||
|     def words_4() -> str: | ||||
|         return str(Utils.rand_sentence.sentence()) | ||||
|  | ||||
|     @staticmethod | ||||
|     def make_word() -> str: | ||||
|         name = "" | ||||
|         name += random.choice(Utils.consonants) | ||||
|         name += random.choice(Utils.vowels) | ||||
|         name += random.choice(Utils.consonants) | ||||
|         name += random.choice(Utils.vowels) | ||||
|         return name | ||||
|  | ||||
|     @staticmethod | ||||
|     def make_words(num: int = 1) -> list[str]: | ||||
|         return [Utils.make_word() for _ in range(num)] | ||||
|  | ||||
|     @staticmethod | ||||
|     def make_name() -> str: | ||||
|         words = Utils.make_words(2) | ||||
|         words = [word.capitalize() for word in words] | ||||
|         return " ".join(words) | ||||
							
								
								
									
										445
									
								
								cromulant/window.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,445 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Any | ||||
| from collections.abc import Callable | ||||
| import signal | ||||
|  | ||||
| from PySide6.QtWidgets import QApplication  # type: ignore | ||||
| from PySide6.QtWidgets import QDialog | ||||
| from PySide6.QtWidgets import QMainWindow | ||||
| from PySide6.QtWidgets import QWidget | ||||
| from PySide6.QtWidgets import QGraphicsScene | ||||
| from PySide6.QtWidgets import QVBoxLayout | ||||
| from PySide6.QtWidgets import QLabel | ||||
| from PySide6.QtWidgets import QPushButton | ||||
| from PySide6.QtWidgets import QHBoxLayout | ||||
| from PySide6.QtWidgets import QScrollArea | ||||
| from PySide6.QtWidgets import QComboBox | ||||
| from PySide6.QtWidgets import QLayout | ||||
| from PySide6.QtWidgets import QSizePolicy | ||||
| from PySide6.QtWidgets import QMessageBox | ||||
| from PySide6.QtWidgets import QLineEdit | ||||
| from PySide6.QtGui import QShortcut  # type: ignore | ||||
| from PySide6.QtGui import QKeySequence | ||||
| from PySide6.QtGui import QFontDatabase | ||||
| from PySide6.QtGui import QIcon | ||||
| from PySide6.QtGui import QKeyEvent | ||||
| from PySide6.QtGui import QMouseEvent | ||||
| from PySide6.QtCore import Qt  # type: ignore | ||||
| from PySide6.QtCore import QUrl | ||||
| from PySide6.QtCore import Signal | ||||
| from PySide6.QtMultimedia import QMediaPlayer  # type: ignore | ||||
| from PySide6.QtMultimedia import QAudioOutput | ||||
|  | ||||
| from .config import Config | ||||
| from .args import Args | ||||
| from .utils import Utils | ||||
|  | ||||
|  | ||||
| class SpecialButton(QPushButton):  # type: ignore | ||||
|     middleClicked = Signal() | ||||
|  | ||||
|     def __init__(self, *args: Any, **kwargs: Any) -> None: | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|     def mousePressEvent(self, e: QMouseEvent) -> None: | ||||
|         if e.button() == Qt.MiddleButton: | ||||
|             self.middleClicked.emit() | ||||
|         else: | ||||
|             super().mousePressEvent(e) | ||||
|  | ||||
|  | ||||
| class SpecialComboBox(QComboBox):  # type: ignore | ||||
|     middleClicked = Signal() | ||||
|  | ||||
|     def __init__(self, *args: Any, **kwargs: Any) -> None: | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|     def mousePressEvent(self, e: QMouseEvent) -> None: | ||||
|         if e.button() == Qt.MiddleButton: | ||||
|             self.middleClicked.emit() | ||||
|         else: | ||||
|             super().mousePressEvent(e) | ||||
|  | ||||
|  | ||||
| class FilterLineEdit(QLineEdit):  # type: ignore | ||||
|     def keyPressEvent(self, e: QKeyEvent) -> None: | ||||
|         if e.key() == Qt.Key_Escape: | ||||
|             self.clear() | ||||
|         else: | ||||
|             super().keyPressEvent(e) | ||||
|  | ||||
|  | ||||
| class RestartDialog(QDialog):  # type: ignore | ||||
|     def __init__(self, sizes: list[str], defindex: int) -> None: | ||||
|         super().__init__() | ||||
|         self.setWindowTitle("Select Option") | ||||
|         self.setFixedSize(300, 150) | ||||
|  | ||||
|         self.layout = QVBoxLayout() | ||||
|  | ||||
|         self.label = QLabel("Size of the population") | ||||
|         self.layout.addWidget(self.label) | ||||
|  | ||||
|         self.size_combo = QComboBox() | ||||
|         self.size_combo.addItems(sizes) | ||||
|         self.size_combo.setCurrentIndex(defindex) | ||||
|         self.layout.addWidget(self.size_combo) | ||||
|  | ||||
|         self.button_layout = QHBoxLayout() | ||||
|  | ||||
|         self.cancel_button = QPushButton("Cancel") | ||||
|         self.cancel_button.clicked.connect(self.reject) | ||||
|         self.button_layout.addWidget(self.cancel_button) | ||||
|  | ||||
|         self.ok_button = QPushButton("OK") | ||||
|         self.ok_button.clicked.connect(self.accept) | ||||
|         self.ok_button.setDefault(True) | ||||
|         self.button_layout.addWidget(self.ok_button) | ||||
|  | ||||
|         self.layout.addLayout(self.button_layout) | ||||
|         self.setLayout(self.layout) | ||||
|         self.setWindowFlags(Qt.Popup) | ||||
|  | ||||
|     def get_data(self) -> dict[str, Any]: | ||||
|         return { | ||||
|             "size": str(self.size_combo.currentText()), | ||||
|         } | ||||
|  | ||||
|  | ||||
| class Window: | ||||
|     app: QApplication | ||||
|     window: QMainWindow | ||||
|     root: QVBoxLayout | ||||
|     view: QVBoxLayout | ||||
|     view_scene: QGraphicsScene | ||||
|     speed: QComboBox | ||||
|     scroll_area: QScrollArea | ||||
|     info: QPushButton | ||||
|     font: str | ||||
|     emoji_font: str | ||||
|     mono_font: str | ||||
|     player: QMediaPlayer | ||||
|     audio: QAudioOutput | ||||
|     filter: QLineEdit | ||||
|  | ||||
|     @staticmethod | ||||
|     def prepare() -> None: | ||||
|         Window.make() | ||||
|         Window.add_buttons() | ||||
|         Window.add_view() | ||||
|         Window.add_footer() | ||||
|         Window.setup_keyboard() | ||||
|  | ||||
|     @staticmethod | ||||
|     def make() -> None: | ||||
|         Window.app = QApplication([]) | ||||
|         program = Args.program or Config.program | ||||
|         Window.app.setApplicationName(program) | ||||
|  | ||||
|         Window.window = QMainWindow() | ||||
|         title = Args.title or Config.title | ||||
|         Window.window.setWindowTitle(title) | ||||
|         width = Args.width or Config.width | ||||
|         height = Args.height or Config.height | ||||
|         Window.window.resize(width, height) | ||||
|  | ||||
|         central_widget = QWidget() | ||||
|         Window.root = QVBoxLayout() | ||||
|         central_widget.setLayout(Window.root) | ||||
|         Window.root.setAlignment(Qt.AlignTop) | ||||
|         Window.window.setCentralWidget(central_widget) | ||||
|         Window.window.setWindowIcon(QIcon(str(Config.icon_path))) | ||||
|         Window.root.setContentsMargins(0, 0, 0, 0) | ||||
|         Window.set_style() | ||||
|  | ||||
|     @staticmethod | ||||
|     def set_style() -> None: | ||||
|         font_id = QFontDatabase.addApplicationFont(str(Config.font_path)) | ||||
|         emoji_font_id = QFontDatabase.addApplicationFont(str(Config.emoji_font_path)) | ||||
|         mono_font_id = QFontDatabase.addApplicationFont(str(Config.mono_font_path)) | ||||
|  | ||||
|         if font_id != -1: | ||||
|             Window.font = QFontDatabase.applicationFontFamilies(font_id)[0] | ||||
|  | ||||
|         if emoji_font_id != -1: | ||||
|             Window.emoji_font = QFontDatabase.applicationFontFamilies(emoji_font_id)[0] | ||||
|  | ||||
|         if mono_font_id != -1: | ||||
|             Window.mono_font = QFontDatabase.applicationFontFamilies(mono_font_id)[0] | ||||
|  | ||||
|         style = f""" | ||||
|  | ||||
|         QWidget {{ | ||||
|             background-color: {Config.background_color}; | ||||
|             color: {Config.text_color}; | ||||
|             font-size: {Config.font_size}px; | ||||
|         }} | ||||
|  | ||||
|         QMenu {{ | ||||
|             background-color: {Config.alt_background_color}; | ||||
|             color: {Config.alt_text_color}; | ||||
|             border: 2px solid {Config.alt_border_color}; | ||||
|         }} | ||||
|  | ||||
|         QMenu::item:selected {{ | ||||
|             background-color: {Config.alt_hover_background_color}; | ||||
|             color: {Config.alt_hover_text_color}; | ||||
|         }} | ||||
|  | ||||
|         QDialog {{ | ||||
|             background-color: {Config.alt_background_color}; | ||||
|             color: {Config.alt_text_color}; | ||||
|             border: 2px solid {Config.alt_border_color}; | ||||
|         }} | ||||
|  | ||||
|         QDialog QLabel {{ | ||||
|             background-color: {Config.alt_background_color}; | ||||
|             color: {Config.alt_text_color}; | ||||
|         }} | ||||
|  | ||||
|         QDialog QPushButton {{ | ||||
|             background-color: {Config.alt_background_color}; | ||||
|             color: {Config.alt_text_color}; | ||||
|         }} | ||||
|  | ||||
|         QDialog QPushButton:hover {{ | ||||
|             background-color: {Config.message_box_button_hover_background_color}; | ||||
|             color: {Config.message_box_button_hover_text_color}; | ||||
|         }} | ||||
|  | ||||
|         QComboBox {{ | ||||
|             selection-background-color: {Config.alt_hover_background_color}; | ||||
|             selection-color: {Config.alt_hover_text_color}; | ||||
|         }} | ||||
|  | ||||
|         QComboBox QAbstractItemView {{ | ||||
|             background-color: {Config.alt_background_color}; | ||||
|             color: {Config.alt_text_color}; | ||||
|             border: 2px solid {Config.alt_border_color}; | ||||
|             padding: 6px; | ||||
|         }} | ||||
|  | ||||
|         QScrollBar:vertical {{ | ||||
|             border: 0px solid transparent; | ||||
|             background: {Config.background_color}; | ||||
|             width: 15px; | ||||
|             margin: 0px 0px 0px 0px; | ||||
|         }} | ||||
|  | ||||
|         QScrollBar::handle:vertical {{ | ||||
|             background: {Config.scrollbar_handle_color}; | ||||
|             min-height: 20px; | ||||
|         }} | ||||
|  | ||||
|         QScrollBar::add-line:vertical {{ | ||||
|             border: none; | ||||
|             background: none; | ||||
|         }} | ||||
|  | ||||
|         QScrollBar::sub-line:vertical {{ | ||||
|             border: none; | ||||
|             background: none; | ||||
|         }} | ||||
|  | ||||
|         QLineEdit {{ | ||||
|             background-color: {Config.input_background_color}; | ||||
|             color: {Config.input_text_color}; | ||||
|             border: 1px solid {Config.input_border_color}; | ||||
|         }} | ||||
|  | ||||
|         QFrame#horizontal_line {{ | ||||
|             background-color: white; | ||||
|             color: white; | ||||
|         }} | ||||
|  | ||||
|         QLabel#menu_label:hover {{ | ||||
|             background-color: {Config.alt_hover_background_color}; | ||||
|         }} | ||||
|  | ||||
|         """.strip() | ||||
|  | ||||
|         Window.app.setStyleSheet(style) | ||||
|  | ||||
|         if Args.mono: | ||||
|             Window.app.setFont(Window.mono_font) | ||||
|         else: | ||||
|             Window.app.setFont(Window.font) | ||||
|  | ||||
|     @staticmethod | ||||
|     def add_buttons() -> None: | ||||
|         from .game import Game | ||||
|         from .filter import Filter | ||||
|  | ||||
|         root = QWidget() | ||||
|         container = QHBoxLayout() | ||||
|  | ||||
|         btn_menu = SpecialButton("Menu") | ||||
|         btn_menu.setToolTip("The main menu\nMiddle Click: Update") | ||||
|         btn_menu.clicked.connect(Game.menu) | ||||
|         btn_menu.middleClicked.connect(Game.force_update) | ||||
|  | ||||
|         Window.speed = SpecialComboBox() | ||||
|         tooltip = "The speed of the updates\n" | ||||
|  | ||||
|         fast = (Args.fast_seconds or Config.fast_seconds) / 60 | ||||
|         tooltip += f"Fast: {Utils.get_timeword(fast)}\n" | ||||
|  | ||||
|         normal = Args.normal_minutes or Config.normal_minutes | ||||
|         tooltip += f"Normal: {Utils.get_timeword(normal)}\n" | ||||
|  | ||||
|         slow = Args.slow_minutes or Config.slow_minutes | ||||
|         tooltip += f"Slow: {Utils.get_timeword(slow)}\n" | ||||
|  | ||||
|         tooltip += "Middle Click: Slow" | ||||
|         Window.speed.setToolTip(tooltip) | ||||
|         Window.speed.addItems(["Fast", "Normal", "Slow", "Paused"]) | ||||
|         Window.speed.setCurrentIndex(1) | ||||
|         Window.speed.currentIndexChanged.connect(Game.update_speed) | ||||
|         Window.speed.middleClicked.connect(Game.slowdown) | ||||
|  | ||||
|         Window.filter = FilterLineEdit() | ||||
|         Window.filter.setPlaceholderText("Filter") | ||||
|         Window.filter.mousePressEvent = lambda e: Window.to_top() | ||||
|         Window.filter.keyReleaseEvent = lambda e: Filter.filter(e) | ||||
|  | ||||
|         container.addWidget(btn_menu, 1) | ||||
|         container.addWidget(Window.speed, 1) | ||||
|         container.addWidget(Window.filter, 1) | ||||
|  | ||||
|         root.setLayout(container) | ||||
|  | ||||
|         if not Args.header: | ||||
|             root.setVisible(False) | ||||
|  | ||||
|         Window.root.addWidget(root) | ||||
|  | ||||
|     @staticmethod | ||||
|     def add_view() -> None: | ||||
|         Window.scroll_area = QScrollArea() | ||||
|         Window.scroll_area.setWidgetResizable(True) | ||||
|  | ||||
|         container = QWidget() | ||||
|         parent = QVBoxLayout(container) | ||||
|         Window.view = QVBoxLayout() | ||||
|         parent.addLayout(Window.view) | ||||
|  | ||||
|         Window.view.setAlignment(Qt.AlignTop) | ||||
|         Window.scroll_area.setWidget(container) | ||||
|         Window.root.addWidget(Window.scroll_area) | ||||
|  | ||||
|     @staticmethod | ||||
|     def start() -> None: | ||||
|         signal.signal(signal.SIGINT, signal.SIG_DFL) | ||||
|         Window.window.show() | ||||
|         Window.app.exec() | ||||
|  | ||||
|     @staticmethod | ||||
|     def close() -> None: | ||||
|         Window.app.quit() | ||||
|  | ||||
|     @staticmethod | ||||
|     def delete_layout(layout: QLayout) -> None: | ||||
|         while layout.count(): | ||||
|             item = layout.takeAt(0) | ||||
|             if item.widget(): | ||||
|                 item.widget().deleteLater() | ||||
|             elif item.layout(): | ||||
|                 Window.delete_layout(item.layout()) | ||||
|  | ||||
|         layout.deleteLater() | ||||
|  | ||||
|     @staticmethod | ||||
|     def expand(widget: QWidget) -> None: | ||||
|         widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) | ||||
|  | ||||
|     @staticmethod | ||||
|     def expand_2(widget: QWidget) -> None: | ||||
|         widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) | ||||
|  | ||||
|     @staticmethod | ||||
|     def clear_view() -> None: | ||||
|         while Window.view.count(): | ||||
|             item = Window.view.takeAt(0) | ||||
|             if item.widget(): | ||||
|                 item.widget().deleteLater() | ||||
|             elif item.layout(): | ||||
|                 Window.delete_layout(item.layout()) | ||||
|  | ||||
|     @staticmethod | ||||
|     def to_top() -> None: | ||||
|         Window.scroll_area.verticalScrollBar().setValue(0) | ||||
|  | ||||
|     @staticmethod | ||||
|     def to_bottom() -> None: | ||||
|         Window.scroll_area.verticalScrollBar().setValue( | ||||
|             Window.scroll_area.verticalScrollBar().maximum() | ||||
|         ) | ||||
|  | ||||
|     @staticmethod | ||||
|     def toggle_scroll() -> None: | ||||
|         maxim = Window.scroll_area.verticalScrollBar().maximum() | ||||
|  | ||||
|         if Window.scroll_area.verticalScrollBar().value() == maxim: | ||||
|             Window.to_top() | ||||
|         else: | ||||
|             Window.to_bottom() | ||||
|  | ||||
|     @staticmethod | ||||
|     def add_footer() -> None: | ||||
|         from .game import Game | ||||
|  | ||||
|         root = QWidget() | ||||
|         root.setContentsMargins(0, 0, 0, 0) | ||||
|         container = QHBoxLayout() | ||||
|         Window.info = SpecialButton("---") | ||||
|  | ||||
|         Window.info.setToolTip( | ||||
|             "Click to scroll to the bottom or top\nMiddle Click: Filter Top" | ||||
|         ) | ||||
|  | ||||
|         Window.info.clicked.connect(Window.toggle_scroll) | ||||
|         Window.info.middleClicked.connect(Game.filter_top) | ||||
|         Window.info.setMinimumSize(35, 35) | ||||
|         container.addWidget(Window.info) | ||||
|         root.setLayout(container) | ||||
|  | ||||
|         if not Args.footer: | ||||
|             root.setVisible(False) | ||||
|  | ||||
|         Window.root.addWidget(root) | ||||
|  | ||||
|     @staticmethod | ||||
|     def play_audio(path: str, on_stop: Callable[..., Any] | None = None) -> None: | ||||
|         Window.player = QMediaPlayer() | ||||
|         Window.audio = QAudioOutput() | ||||
|         Window.player.setAudioOutput(Window.audio) | ||||
|         Window.player.setSource(QUrl.fromLocalFile(path)) | ||||
|         Window.audio.setVolume(100) | ||||
|  | ||||
|         def handle_state_change(state: QMediaPlayer.State) -> None: | ||||
|             if state == QMediaPlayer.StoppedState: | ||||
|                 if on_stop: | ||||
|                     on_stop() | ||||
|  | ||||
|         Window.player.playbackStateChanged.connect(handle_state_change) | ||||
|         Window.player.play() | ||||
|  | ||||
|     @staticmethod | ||||
|     def stop_audio() -> None: | ||||
|         Window.player.stop() | ||||
|  | ||||
|     @staticmethod | ||||
|     def alert(message: str) -> None: | ||||
|         msg_box = QMessageBox() | ||||
|         msg_box.setWindowFlags(Qt.Popup) | ||||
|         msg_box.setIcon(QMessageBox.Information) | ||||
|         msg_box.setText(message) | ||||
|         msg_box.setWindowTitle("Information") | ||||
|         msg_box.setStandardButtons(QMessageBox.Ok) | ||||
|         msg_box.exec() | ||||
|  | ||||
|     @staticmethod | ||||
|     def setup_keyboard() -> None: | ||||
|         on_enter = QShortcut(QKeySequence(Qt.Key_Return), Window.window) | ||||
|         on_enter.activated.connect(Window.toggle_scroll) | ||||
							
								
								
									
										25
									
								
								more.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,25 @@ | ||||
| ## Propaganda | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| ## Assets | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| ## Soundtrack | ||||
|  | ||||
| [March of The Cyber Ants](cromulant/audio/March%20of%20the%20Cyber%20Ants.mp3) | ||||
							
								
								
									
										5
									
								
								requirements.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | ||||
| PySide6 == 6.7.2 | ||||
| appdirs == 1.4.4 | ||||
| wonderwords == 2.2.0 | ||||
| fonttools == 4.53.1 | ||||
| pre-commit == 3.7.1 | ||||
							
								
								
									
										6
									
								
								run.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,6 @@ | ||||
| #!/usr/bin/env bash | ||||
|  | ||||
| root="$(dirname "$(readlink -f "$0")")" | ||||
| cd "$root" | ||||
|  | ||||
| venv/bin/python -m cromulant.main "$@" | ||||
							
								
								
									
										9
									
								
								screenshots.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
							
								
								
									
										69
									
								
								setup.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,69 @@ | ||||
| from setuptools import setup, find_packages | ||||
| from pathlib import Path | ||||
| import shutil | ||||
| import json | ||||
| import platform | ||||
|  | ||||
| with open("cromulant/manifest.json", "r") as file: | ||||
|     manifest = json.load(file) | ||||
|  | ||||
| title = manifest["title"] | ||||
| program = manifest["program"] | ||||
| version = manifest["version"] | ||||
|  | ||||
|  | ||||
| def _post_install(): | ||||
|     system = platform.system() | ||||
|  | ||||
|     if system == "Linux": | ||||
|         try: | ||||
|             _copy_icon_file() | ||||
|             _create_desktop_file() | ||||
|         except Exception as e: | ||||
|             print(f"Error during post install: {e}") | ||||
|  | ||||
|  | ||||
| def _copy_icon_file(): | ||||
|     source = Path(f"{program}/img/icon_1.jpg").expanduser().resolve() | ||||
|     destination = Path(f"~/.local/share/icons/{program}.png").expanduser().resolve() | ||||
|     shutil.copy2(source, destination) | ||||
|  | ||||
|  | ||||
| def _create_desktop_file(): | ||||
|     content = f"""[Desktop Entry] | ||||
| Version=1.0 | ||||
| Name={title} | ||||
| Exec={Path(f"~/.local/bin/{program}").expanduser().resolve()} | ||||
| Icon={Path(f"~/.local/share/icons/{program}.png").expanduser().resolve()} | ||||
| Terminal=false | ||||
| Type=Application | ||||
| Categories=Utility; | ||||
| """ | ||||
|  | ||||
|     file_path = Path(f"~/.local/share/applications/{program}.desktop").expanduser().resolve() | ||||
|  | ||||
|     with open(file_path, 'w') as f: | ||||
|         f.write(content) | ||||
|  | ||||
|  | ||||
| with open("requirements.txt") as f: | ||||
|     requirements = f.read().splitlines() | ||||
|  | ||||
| package_data = {} | ||||
| package_data[program] = ["**/*.png", "**/*.jpg", "**/*.json", "**/*.mp3", "**/*.ttf"] | ||||
|  | ||||
| setup( | ||||
|     name=title, | ||||
|     version=version, | ||||
|     install_requires=requirements, | ||||
|     packages=find_packages(where="."), | ||||
|     package_dir={"": "."}, | ||||
|     package_data=package_data, | ||||
|     entry_points={ | ||||
|         "console_scripts": [ | ||||
|             f"{program}={program}.main:main", | ||||
|         ], | ||||
|     }, | ||||
| ) | ||||
|  | ||||
| _post_install() | ||||
							
								
								
									
										23
									
								
								utils/tag.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,23 @@ | ||||
| #!/usr/bin/env python | ||||
|  | ||||
| # This is used to create a tag in the git repo | ||||
| # You probably don't want to run this | ||||
|  | ||||
| # pacman: python-gitpython | ||||
| import os | ||||
| import git | ||||
| import json | ||||
| from pathlib import Path | ||||
|  | ||||
| here = Path(__file__).resolve() | ||||
| parent = here.parent.parent | ||||
| os.chdir(parent) | ||||
|  | ||||
| with open("cromulant/manifest.json") as f: | ||||
|     manifest = json.loads(f.read()) | ||||
|  | ||||
| version = manifest["version"] | ||||
| repo = git.Repo(".") | ||||
| repo.create_tag(version) | ||||
| repo.remotes.origin.push(version) | ||||
| print(f"Created tag: {version}") | ||||
 Auric Vente
					Auric Vente