diff --git a/monumental-monsteras/.github/workflows/lint.yaml b/monumental-monsteras/.github/workflows/lint.yaml new file mode 100644 index 00000000..7f67e803 --- /dev/null +++ b/monumental-monsteras/.github/workflows/lint.yaml @@ -0,0 +1,35 @@ +# GitHub Action workflow enforcing our code style. + +name: Lint + +# Trigger the workflow on both push (to the main repository, on the main branch) +# and pull requests (against the main repository, but from any repo, from any branch). +on: + push: + branches: + - main + pull_request: + +# Brand new concurrency setting! This ensures that not more than one run can be triggered for the same commit. +# It is useful for pull requests coming from the main repository since both triggers will match. +concurrency: lint-${{ github.sha }} + +jobs: + lint: + runs-on: ubuntu-latest + + env: + # The Python version your project uses. Feel free to change this if required. + PYTHON_VERSION: "3.12" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Run pre-commit hooks + uses: pre-commit/action@v3.0.1 diff --git a/monumental-monsteras/.gitignore b/monumental-monsteras/.gitignore new file mode 100644 index 00000000..69b17648 --- /dev/null +++ b/monumental-monsteras/.gitignore @@ -0,0 +1,39 @@ +# Files generated by the interpreter +__pycache__/ +*.py[cod] + +# Environment specific +.venv +venv +.env +env + +# Unittest reports +.coverage* + +# Logs +*.log + +# PyEnv version selector +.python-version + +# Built objects +*.so +dist/ +build/ + +# IDEs +# PyCharm +.idea/ +# VSCode +.vscode/ +# MacOS +.DS_Store + +# Since uv is not required for the project and all dependancies are pinned, +# ignore the lock file for the convenience of people using uv +uv.lock + +# Useless build-specific files generated if you run the project a certain way +*.egg-info/ +*.egg diff --git a/monumental-monsteras/.pre-commit-config.yaml b/monumental-monsteras/.pre-commit-config.yaml new file mode 100644 index 00000000..c0a8de23 --- /dev/null +++ b/monumental-monsteras/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +# Pre-commit configuration. +# See https://github.com/python-discord/code-jam-template/tree/main#pre-commit-run-linting-before-committing + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.2 + hooks: + - id: ruff-check + - id: ruff-format diff --git a/monumental-monsteras/LICENSE.txt b/monumental-monsteras/LICENSE.txt new file mode 100644 index 00000000..e70f173b --- /dev/null +++ b/monumental-monsteras/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2025 Mannyvv, afx8732, enskyeing, husseinhirani, jks85, MeGaGiGaGon + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/monumental-monsteras/README-template.md b/monumental-monsteras/README-template.md new file mode 100644 index 00000000..3bf4bfba --- /dev/null +++ b/monumental-monsteras/README-template.md @@ -0,0 +1,186 @@ +# Python Discord Code Jam Repository Template + +## A primer + +Hello code jam participants! We've put together this repository template for you to use in [our code jams](https://pythondiscord.com/events/) or even other Python events! + +This document contains the following information: + +1. [What does this template contain?](#what-does-this-template-contain) +2. [How do I use this template?](#how-do-i-use-this-template) +3. [How do I adapt this template to my project?](#how-do-i-adapt-this-template-to-my-project) + +> [!TIP] +> You can also look at [our style guide](https://pythondiscord.com/events/code-jams/code-style-guide/) to get more information about what we consider a maintainable code style. + +## What does this template contain? + +Here is a quick rundown of what each file in this repository contains: + +- [`LICENSE.txt`](LICENSE.txt): [The MIT License](https://opensource.org/licenses/MIT), an OSS approved license which grants rights to everyone to use and modify your project, and limits your liability. We highly recommend you to read the license. +- [`.gitignore`](.gitignore): A list of files and directories that will be ignored by Git. Most of them are auto-generated or contain data that you wouldn't want to share publicly. +- [`pyproject.toml`](pyproject.toml): Configuration and metadata for the project, as well as the linting tool Ruff. If you're interested, you can read more about `pyproject.toml` in the [Python Packaging documentation](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/). +- [`.pre-commit-config.yaml`](.pre-commit-config.yaml): The configuration of the [pre-commit](https://pre-commit.com/) tool. +- [`.github/workflows/lint.yaml`](.github/workflows/lint.yaml): A [GitHub Actions](https://github.com/features/actions) workflow, a set of actions run by GitHub on their server after each push, to ensure the style requirements are met. + +Each of these files have comments for you to understand easily, and modify to fit your needs. + +### Ruff: general style rules + +Our first tool is Ruff. It will check your codebase and warn you about any non-conforming lines. +It is run with the command `ruff check` in the project root. + +Here is a sample output: + +```shell +$ ruff check +app.py:1:5: N802 Function name `helloWorld` should be lowercase +app.py:1:5: ANN201 Missing return type annotation for public function `helloWorld` +app.py:2:5: D400 First line should end with a period +app.py:2:5: D403 First word of the first line should be capitalized: `docstring` -> `Docstring` +app.py:3:15: W292 No newline at end of file +Found 5 errors. +``` + +Each line corresponds to an error. The first part is the file path, then the line number, and the column index. +Then comes the error code, a unique identifier of the error, and then a human-readable message. + +If, for any reason, you do not wish to comply with this specific error on a specific line, you can add `# noqa: CODE` at the end of the line. +For example: + +```python +def helloWorld(): # noqa: N802 + ... + +``` + +This will ignore the function naming issue and pass linting. + +> [!WARNING] +> We do not recommend ignoring errors unless you have a good reason to do so. + +### Ruff: formatting + +Ruff also comes with a formatter, which can be run with the command `ruff format`. +It follows the same code style enforced by [Black](https://black.readthedocs.io/en/stable/index.html), so there's no need to pick between them. + +### Pre-commit: run linting before committing + +The second tool doesn't check your code, but rather makes sure that you actually *do* check it. + +It makes use of a feature called [Git hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) which allow you to run a piece of code before running `git commit`. +The good thing about it is that it will cancel your commit if the lint doesn't pass. You won't have to wait for GitHub Actions to report issues and have a second fix commit. + +It is *installed* by running `pre-commit install` and can be run manually by calling only `pre-commit`. + +[Lint before you push!](https://soundcloud.com/lemonsaurusrex/lint-before-you-push) + +#### List of hooks + +- `check-toml`: Lints and corrects your TOML files. +- `check-yaml`: Lints and corrects your YAML files. +- `end-of-file-fixer`: Makes sure you always have an empty line at the end of your file. +- `trailing-whitespace`: Removes whitespaces at the end of each line. +- `ruff-check`: Runs the Ruff linter. +- `ruff-format`: Runs the Ruff formatter. + +## How do I use this template? + +### Creating your team repository + +One person in the team, preferably the leader, will have to create the repository and add other members as collaborators. + +1. In the top right corner of your screen, where **Clone** usually is, you have a **Use this template** button to click. + ![use-this-template-button](https://docs.github.com/assets/images/help/repository/use-this-template-button.png) +2. Give the repository a name and a description. + ![create-repository-name](https://docs.github.com/assets/images/help/repository/create-repository-name.png) +3. Click **Create repository from template**. +4. Click **Settings** in your newly created repository. + ![repo-actions-settings](https://docs.github.com/assets/images/help/repository/repo-actions-settings.png) +5. In the "Access" section of the sidebar, click **Collaborators**. + ![collaborators-settings](https://github.com/python-discord/code-jam-template/assets/63936253/c150110e-d1b5-4e4d-93e0-0a2cf1de352b) +6. Click **Add people**. +7. Insert the names of each of your teammates, and invite them. Once they have accepted the invitation in their email, they will have write access to the repository. + +You are now ready to go! Sit down, relax, and wait for the kickstart! + +> [!IMPORTANT] +> Don't forget to change the project name, description, and authors at the top of the [`pyproject.toml`](pyproject.toml) file, and swap "Python Discord" in the [`LICENSE.txt`](LICENSE.txt) file for the name of each of your team members or the name of your team *after* the start of the code jam. + +### Using the default pip setup + +Our default setup includes a dependency group to be used with a [virtual environment](https://docs.python.org/3/library/venv.html). +It works with pip and uv, and we recommend this if you have never used any other dependency manager, although if you have, feel free to switch to it. +More on that [below](#how-do-i-adapt-this-template-to-my-project). + +Dependency groups are a relatively new feature, specified in [PEP 735](https://peps.python.org/pep-0735/). +You can read more about them in the [Python Packaging User Guide](https://packaging.python.org/en/latest/specifications/dependency-groups/). + +#### Creating the environment + +Create a virtual environment in the folder `.venv`. + +```shell +python -m venv .venv +``` + +#### Entering the environment + +It will change based on your operating system and shell. + +```shell +# Linux, Bash +$ source .venv/bin/activate +# Linux, Fish +$ source .venv/bin/activate.fish +# Linux, Csh +$ source .venv/bin/activate.csh +# Linux, PowerShell Core +$ .venv/bin/Activate.ps1 +# Windows, cmd.exe +> .venv\Scripts\activate.bat +# Windows, PowerShell +> .venv\Scripts\Activate.ps1 +``` + +#### Installing the dependencies + +Once the environment is created and activated, use this command to install the development dependencies. + +```shell +pip install --group dev +``` + +#### Exiting the environment + +Interestingly enough, it is the same for every platform. + +```shell +deactivate +``` + +Once the environment is activated, all the commands listed previously should work. + +> [!IMPORTANT] +> We highly recommend that you run `pre-commit install` as soon as possible. + +## How do I adapt this template to my project? + +If you wish to use Pipenv or Poetry, you will have to move the dependencies in [`pyproject.toml`](pyproject.toml) to the development dependencies of your tool. + +We've included a porting to both [Poetry](samples/pyproject.toml) and [Pipenv](samples/Pipfile) in the [`samples` folder](samples). +Note that the Poetry [`pyproject.toml`](samples/pyproject.toml) file does not include the Ruff configuration, so if you simply replace the file then the Ruff configuration will be lost. + +When installing new dependencies, don't forget to [pin](https://pip.pypa.io/en/stable/topics/repeatable-installs/#pinning-the-package-versions) them by adding a version tag at the end. +For example, if I wish to install [Click](https://click.palletsprojects.com/en/8.1.x/), a quick look at [PyPI](https://pypi.org/project/click/) tells me that `8.1.7` is the latest version. +I will then add `click~=8.1`, without the last number, to my requirements file or dependency manager. + +> [!IMPORTANT] +> A code jam project is left unmaintained after the end of the event. If the dependencies aren't pinned, the project will break after any major change in an API. + +## Final words + +> [!IMPORTANT] +> Don't forget to replace this README with an actual description of your project! Images are also welcome! + +We hope this template will be helpful. Good luck in the jam! diff --git a/monumental-monsteras/README.md b/monumental-monsteras/README.md new file mode 100644 index 00000000..5cfd18f7 --- /dev/null +++ b/monumental-monsteras/README.md @@ -0,0 +1,58 @@ +# Monumental Monsteras CJ25 Project +Monumental Monsteras CJ25 Project is a typing speed test, +but with a twist: You cannot type with a normal keyboard. +You can only use the **wrong tool for the job**. + +Try different wrong methods of writing text, with a score at +the end if you would like to flex on your friends. + +Input methods: + +# Running the project +## Using `uv` (recommended) + +The recommended way to run the project is using `uv`. + +If you do not have `uv` installed, see https://docs.astral.sh/uv/getting-started/installation/ + +``` +$ git clone https://github.com/Mannyvv/cj25-monumental-monsteras-team-repo.git +$ cd cj25-monumental-monsteras-team-repo.git +$ uv run src/main.py +``` + +## Without `uv` + +``` +$ git clone https://github.com/Mannyvv/cj25-monumental-monsteras-team-repo.git +$ cd cj25-monumental-monsteras-team-repo.git +$ py -3.12 -m venv .venv +$ py -m pip install . +$ py src/main.py +``` + +# Contributing +## Setting up the project for development +If you do not have `pre-commit` installed, see https://pre-commit.com/#installation + +You can also use `uvx pre-commit` to run `pre-commit` commands without permanently installing it. + +Once you have `pre-commit` installed, run this command to set up the commit hooks. +``` +$ pre-commit install +``` + +## Development process +If the change you are making is large, open a new +issue and self-assign to make sure no duplicate work is done. + +When making a change: +1. Make a new branch on the main repository +2. Make commits to the branch +3. Open a PR from that branch to main + +You can run the pre-commit checks locally with: +``` +$ pre-commit run -a +``` +If you installed the commit hook in the previous step, they should also be run locally on commits. diff --git a/monumental-monsteras/pyproject.toml b/monumental-monsteras/pyproject.toml new file mode 100644 index 00000000..7636100b --- /dev/null +++ b/monumental-monsteras/pyproject.toml @@ -0,0 +1,76 @@ +[project] +# This section contains metadata about your project. +# Don't forget to change the name, description, and authors to match your project! +name = "code-jam-template" +description = "Add your description here" +authors = [ + { name = "Mannyvv" }, + { name = "afx8732" }, + { name = "enskyeing" }, + { name = "husseinhirani" }, + { name = "jks85" }, + { name = "MeGaGiGaGon" }, +] +version = "0.1.0" +readme = "README.md" +# Target Python 3.12. If you decide to use a different version of Python +# you will need to update this value. +requires-python = ">=3.12" +dependencies = [ + "nicegui~=2.22.2", + "Faker~=37.5.3", +] + +[dependency-groups] +# This `dev` group contains all the development requirements for our linting toolchain. +# Don't forget to pin your dependencies! +# This list will have to be migrated if you wish to use another dependency manager. +dev = [ + "pre-commit~=4.2.0", + "ruff~=0.12.2", +] + +[tool.ruff] +# Increase the line length. This breaks PEP8 but it is way easier to work with. +# The original reason for this limit was a standard vim terminal is only 79 characters, +# but this doesn't really apply anymore. +line-length = 119 +# Automatically fix auto-fixable issues. +fix = true +# The directory containing the source code. If you choose a different project layout +# you will need to update this value. +src = ["src"] + +[tool.ruff.lint] +# Enable all linting rules. +select = ["ALL"] +# Ignore some of the most obnoxious linting errors. +ignore = [ + # Missing docstrings. + "D100", + "D104", + "D105", + "D106", + "D107", + # Docstring whitespace. + "D203", + "D213", + # Docstring punctuation. + "D415", + # Docstring quotes. + "D301", + # Builtins. + "A", + # Print statements. + "T20", + # TODOs. + "TD002", + "TD003", + "FIX", + # Conflicts with formatter. + "COM812", + # Boolean positional arguments + "FBT001", + "FBT002", + "FBT003", +] diff --git a/monumental-monsteras/src/audio_style_input/__init__.py b/monumental-monsteras/src/audio_style_input/__init__.py new file mode 100644 index 00000000..16bfaeb6 --- /dev/null +++ b/monumental-monsteras/src/audio_style_input/__init__.py @@ -0,0 +1,280 @@ +import asyncio +import string +from pathlib import Path +from typing import override + +from nicegui import app, ui + +from color_style import ColorStyle +from input_method_proto import IInputMethod, TextUpdateCallback + +COLOR_STYLE = ColorStyle() + +media = Path("./static") +app.add_media_files("/media", media) + +char_selection = [string.ascii_uppercase, string.ascii_lowercase, string.punctuation] + + +class AudioEditorComponent(IInputMethod): + """Render the audio editor page with spinning record and letter spinner.""" + + def __init__(self) -> None: + self._text_update_callback: TextUpdateCallback | None = None + self.current_char_selection_index_container = [0] + self.current_chars_selected = char_selection[0] + self.current_letter_index_container = [0] + self.play_pause_toggle = [False] + self.rotation_container = [0] + self.normal_spin_speed = 5 + self.boosted_spin_speed = 10 + self.spin_speed_container = [self.normal_spin_speed] + self.spin_direction_container = [1] + self.user_text_container = "" + + self.timer_task = None + self.spin_task = None + + self.intro_card, self.start_button = self.create_intro_card() + self.main_content, self.record, self.label, self.buttons_row, self.buttons_row_2 = self.create_main_content() + + self.main_track = ( + ui.audio("https://www.soundhelix.com/examples/mp3/SoundHelix-Song-6.mp3") + .props(remove="controls") + .props("loop") + ) + self.rewind_sound = ui.audio("/media/sounds/rewind.mp3").style("display:none") + self.fast_forward_sound = ui.audio("/media/sounds/fast_forward.mp3").style("display:none") + + self.setup_buttons() + self.start_button.on("click", self.start_audio_editor) + + def create_intro_card(self) -> tuple[ui.card, ui.button]: + """Create the intro card with title and start button. + + Returns: + tuple: (intro_card, start_button) + + """ + intro_card = ui.card().classes( + f"w-full h-full flex justify-center items-center bg-[{COLOR_STYLE.secondary_bg}]" + ) + with intro_card, ui.card().classes("no-shadow justify-center items-center"): + ui.label("WPM Battle: DJ Edition").classes("text-5xl font-bold") + ui.label("Use an audio editor to test your typing skills").classes("text-xl") + start_button = ui.button("Get started!", color=COLOR_STYLE.primary) + return intro_card, start_button + + def create_main_content(self) -> tuple[ui.column, ui.image, ui.chip, ui.row, ui.row]: + """Create main content with record image, letter label, and button row. + + Returns: + tuple: (main_content container, record image, label, buttons row) + + """ + main_content = ui.column().classes("w-full h-full items-center gap-4 #2b87d1").style("display:none") + with ( + main_content, + ui.card().classes( + f"gap-4 w-full h-full flex flex-col justify-center items-center bg-[{COLOR_STYLE.secondary_bg}] px-16" + ), + ui.element("div").classes("flex flex-row w-full justify-between"), + ): + with ui.element("div").classes("flex flex-col w-1/2 h-full justify-center items-center gap-4"): + chip = ui.chip(text="Current letter: A", color=f"{COLOR_STYLE.contrast}").classes( + "relative text-2xl top-[-100px]" + ) + buttons_row = ui.row().style("gap: 10px") + buttons_row_2 = ui.row().style("gap: 10x") + + with ui.element("div").classes("flex flex-col w-1/2 h-full justify-center items-center gap-4"): + record = ( + ui.image( + "/media/images/record.png", + ) + .style("transition: transform 0.05s linear;") + .classes("w-1/2") + ) + + return main_content, record, chip, buttons_row, buttons_row_2 + + def cycle_char_select(self) -> None: + """Select character set from Capital, Lower, and Special characters.""" + self.current_char_selection_index_container[0] = (self.current_char_selection_index_container[0] + 1) % len( + char_selection, + ) + self.current_chars_selected = char_selection[self.current_char_selection_index_container[0]] + self.current_letter_index_container[0] = (self.current_letter_index_container[0] + 1) % len( + self.current_chars_selected, + ) + + async def spin_continuous(self) -> None: + """Continuously rotate the record image based on spin speed and direction.""" + while True: + self.rotation_container[0] += self.spin_direction_container[0] * self.spin_speed_container[0] + self.record.style(f"transform: rotate({self.rotation_container[0]}deg);") + await asyncio.sleep(0.05) + + async def letter_spinner_task(self) -> None: + """Continuously update the label with the current letter, cycling through letters.""" + while True: + self.label.set_text( + f"Current letter: {self.current_chars_selected[self.current_letter_index_container[0]]}", + ) + self.current_letter_index_container[0] = (self.current_letter_index_container[0] + 1) % len( + self.current_chars_selected, + ) + await asyncio.sleep(0.5) + + def start_spinning(self, *, clockwise: bool = True) -> None: + """Start spinning the record image. + + Args: + clockwise (bool): Direction of spin; True for clockwise, False for counterclockwise. + + """ + self.spin_direction_container[0] = 1 if clockwise else -1 + if self.spin_task is None or self.spin_task.done(): + self.spin_task = asyncio.create_task(self.spin_continuous()) + + def stop_spinning(self) -> None: + """Stop spinning the record image.""" + if self.spin_task: + self.spin_task.cancel() + + async def speed_boost(self, final_direction: int = 1) -> None: + """Temporarily increase spin speed and then restore it. + + Args: + final_direction (int): Direction to set after boost (1 or -1). + + """ + self.spin_speed_container[0] = self.boosted_spin_speed + await asyncio.sleep(1) + self.spin_speed_container[0] = self.normal_spin_speed + self.spin_direction_container[0] = final_direction + + def toggle_play_pause(self) -> None: + """Toggle play_pause state.""" + self.play_pause_toggle[0] = not self.play_pause_toggle[0] + self.play_pause_handler() + + def play_pause_handler(self) -> None: + """Play and puase the letter spinner and spinning.""" + toggle = self.play_pause_toggle[0] + + if toggle: + self.main_track.play() + self.on_play() + else: + self.main_track.pause() + self.on_pause() + + def on_play(self) -> None: + """Start letter spinner and spinning.""" + if self.timer_task is None or self.timer_task.done(): + self.timer_task = asyncio.create_task(self.letter_spinner_task()) + self.start_spinning(clockwise=True) + + def on_pause(self) -> None: + """Stop letter spinner and spinning.""" + if self.timer_task: + self.timer_task.cancel() + self.stop_spinning() + + def play_rewind_sound(self) -> None: + """Play rewind sound effect.""" + self.rewind_sound.play() + + def play_fast_forward_sound(self) -> None: + """Play fast forward sound effect.""" + self.fast_forward_sound.play() + + def forward_3(self) -> None: + """Skip forward 3 letters with sound and speed boost.""" + self.current_letter_index_container[0] = (self.current_letter_index_container[0] + 3) % len( + self.current_chars_selected, + ) + self.play_fast_forward_sound() + self.start_spinning(clockwise=True) + self._forward_3_task = asyncio.create_task(self.speed_boost(final_direction=1)) + + def rewind_3(self) -> None: + """Skip backward 3 letters with sound and speed boost.""" + self.current_letter_index_container[0] = (self.current_letter_index_container[0] - 3) % len( + self.current_chars_selected, + ) + self.play_rewind_sound() + self.start_spinning(clockwise=False) + self._speed_boost_task = asyncio.create_task(self.speed_boost(final_direction=1)) + + def setup_buttons(self) -> None: + """Create UI buttons with their event handlers.""" + with self.buttons_row, ui.button_group().classes("gap-1"): + ui.button("Rewind 3 Seconds", color="#d18b2b", icon="fast_rewind", on_click=self.rewind_3) + ui.button("Forward 3 Seconds", color="#d18b2b", icon="fast_forward", on_click=self.forward_3) + ui.button("Next Set of Chars", icon="skip_next", on_click=self.cycle_char_select) + with self.buttons_row_2, ui.button_group().classes("gap-1"): + ui.button( + "Play/Pause", + color="#2bd157", + icon="not_started", + on_click=lambda: [self.toggle_play_pause()], + ) + ui.button("Eject", color="#d18b2b", icon="eject", on_click=self._delete_letter_handler) + ui.button("Record", color="red", icon="radio_button_checked", on_click=self._select_letter_handler) + ui.button("Mute", color="grey", icon="do_not_disturb", on_click=self._add_space_handler) + + def _select_letter_handler(self) -> None: + """Notify selected letter and trigger text update callback.""" + char = self.current_chars_selected[self.current_letter_index_container[0] - 1] + ui.notify(f"You selected: {char}") + self.select_char(char) + + def start_audio_editor(self) -> None: + """Hide intro card and show main content.""" + self.intro_card.style("display:none") + self.main_content.style("display:flex") + + @override + def on_text_update(self, callback: TextUpdateCallback) -> None: + """Register a callback to be called whenever the text updates. + + Args: + callback (TextUpdateCallback): Function called with updated text. + + """ + self._text_update_callback = callback + + def select_char(self, char: str) -> None: + """Call the registered callback with the selected letter. + + Args: + char (str): The letter selected by the user. + + """ + if char != "back_space": + self.user_text_container += char + else: + self.user_text_container = self.user_text_container[:-1] + + if self._text_update_callback: + self._text_update_callback(self.user_text_container) + + def _delete_letter_handler(self, char: str = "back_space") -> None: + """Delete the last letter in user string thus far. + + Args: + char(str): The code to delete last leter (back_space) + + """ + self.select_char(char) + + def _add_space_handler(self, char: str = " ") -> None: + """Add a space in user string thus far. + + Args: + char(str): The space characer to add. + + """ + self.select_char(char) diff --git a/monumental-monsteras/src/color_mixer_input/__init__.py b/monumental-monsteras/src/color_mixer_input/__init__.py new file mode 100644 index 00000000..0be23638 --- /dev/null +++ b/monumental-monsteras/src/color_mixer_input/__init__.py @@ -0,0 +1,305 @@ +import string +from functools import partial + +from nicegui import ui +from nicegui.events import ColorPickEventArguments + +from color_style import ColorStyle +from input_method_proto import IInputMethod, TextUpdateCallback + +COLOR_STYLE = ColorStyle() + + +class ColorInputComponent(IInputMethod): + """Implements the color-based typing input page. + + Allows user to type using the color palette for letters, spaces, and backspaces, and UI buttons/switches for + special characters ('.', '!', Shift, etc.) + """ + + def __init__(self) -> None: + self.text_callback: TextUpdateCallback | None = None + self.typed_text = "" + self.typed_char = None + self.confirmed_char = None + self.selected_color = None + self.shift_key_on = False + ( + self.color_picker_row, + self.color_label, + self.input_label, + self.command_buttons_row, + self.special_char_buttons_row, + ) = self.create_ui_content() + self.setup_ui_buttons() + + self.color_dict = { + "aqua": "#00FFFF", + "blue": "#0000FF", + "camouflage": "#3C3910", + "darkblue": "#00008B", + "emerald": "00674F", + "fuchsia": "#FF00FF", + "gray": "#7F7F7F", + "hotpink": "#FF69B4", + "indigo": "#4B0082", + "jasmine": "#F8DE7E", + "khaki": "#F0E68C", + "lime": "#00FF00", + "maroon": "#800000", + "navy": "#000080", + "orange": "#FFA500", + "purple": "#800080", + "quicksilver": "#A6A6A6", + "red": "#FF0000", + "salmon": "#FA8072", + "teal": "#008080", + "ube": "#8878C3", + "viridian": "#40826D", + "walnut": "#773F1A", + "xanadu": "#738678", + "yellow": "#FFFF00", + "zara": "#B48784", + "black": "#000000", + "white": "#FFFFFF", + } + + def on_text_update(self, callback: TextUpdateCallback) -> None: + """Handle callbacks for text updates.""" + self.text_callback = callback + + def special_character_handler(self, char: str) -> None: + """Handle special character events. + + This function handles special characters (e.g. '.', '!', ',', '?'). These characters are input using ui.button + elements. Special characters are automatically output to the WPM page. + """ + self.selected_color = None + self.typed_text += char + self.typed_char = char + self.confirmed_char = char + self.update_helper_text() + self.update_confirmation_text() + + def confirm_letter_handler(self) -> None: + """Handle event when user clicks 'Confirm Letter'. + + After user clicks confirm, letter is typed and the WPM page updates the text. + """ + alphabet = string.ascii_letters + if self.typed_char in alphabet: + self.confirmed_char = self.typed_char + self.typed_text += self.confirmed_char + self.update_confirmation_text() + + def color_handler(self, element: ColorPickEventArguments) -> None: + """Handle events when user selects a color. + + Identifies closest color in dictionary. Maps that color to a letter or action. + + Black maps to backspace and white maps to space. Otherwise, colors map to the first letter of their name in the + color dictionary. + + Letters must be confirmed by the user before being output to the WPM page. Special characters are automatically + output to the WPM page. + """ + selected_color_hex = element.color + self.selected_color = self.find_closest_member(selected_color_hex) + + if self.selected_color == "black": + self.typed_char = "backspace" + if len(self.typed_text) > 0: + self.typed_text = self.typed_text[:-1] + self.confirmed_char = self.typed_char + elif self.selected_color == "white": + self.typed_text += " " + self.typed_char = "space" + self.confirmed_char = self.typed_char + elif self.shift_key_on: + self.typed_char = self.selected_color[0].upper() + else: + self.typed_char = self.selected_color[0] + + if self.typed_char in ["backspace", "space"]: + self.update_confirmation_text() + self.update_helper_text() + + def shift_handler(self) -> None: + """Switch shift key on/off. The color_handler() method deals with capitalizing output.""" + self.shift_key_on = not self.shift_key_on + + def update_helper_text(self) -> None: + """Update helper text on page. + + Displays the color and (if applicable) current character selected based on the user click." + """ + self.color_label.text = f"Current Color: {self.selected_color}" + self.input_label.text = f"Current Input: {self.typed_char}" + self.color_label.update() + self.input_label.update() + + def update_confirmation_text(self) -> None: + """Update confirmed text on page. + + Display the confirmed character selected and all confirmed text typed thus far. + + """ + if self.text_callback: + self.text_callback(self.typed_text) + + def create_ui_content(self) -> tuple[ui.row, ui.chip, ui.chip, ui.row, ui.row]: + """Create the frame to hold the color picker, text labels, and buttons. + + Returns: + tuple: (row for color picker, label for color selected, row for commands, row for special characters) + + """ + with ui.element("div").classes("flex flex-col items-center justify-center w-full h-full"): + with ui.element("div").classes("flex flex-row justify-center w-1/2 h-full"): + color_picker_row = ui.row() + with ui.element("div").classes("w-1/2 h-full flex flex-col items-center justify-center gap-16"): + with ui.element("div").classes("flex flex-col items-center justify-center gap-2"): + color_chip = ui.chip( + "Current Color: None", + color=COLOR_STYLE.contrast, + text_color=COLOR_STYLE.primary_bg, + ) + input_chip = ui.chip( + "Current Input: ", + color=COLOR_STYLE.contrast, + text_color=COLOR_STYLE.primary_bg, + ) + with ui.element("div").classes("flex flex-col items-center justify-center gap-4"): + command_buttons_row = ui.row().style("gap: 10px") + special_char_buttons_row = ui.row().style("gap: 10px") + + return color_picker_row, color_chip, input_chip, command_buttons_row, special_char_buttons_row + + def setup_ui_buttons(self) -> None: + """Create the buttons and other dynamic elements (e.g. labels, switches) on page.""" + with self.color_picker_row, ui.button(icon="colorize").style("opacity:0; pointer-events:none"): + ui.color_picker(on_pick=self.color_handler, value=True).props("persistent").classes("w-[300px] h-auto") + + with self.command_buttons_row, ui.button_group().classes("gap-1"): + ui.switch("CAPS LOCK", on_change=self.shift_handler).classes( + f"bg-[{COLOR_STYLE.secondary}] text-white pr-[10px]" + ) + ui.button("Confirm Letter", on_click=self.confirm_letter_handler, color=COLOR_STYLE.secondary).classes( + "bg-blue-500 text-white" + ) + + with self.special_char_buttons_row, ui.button_group().classes("gap-1"): + # creating wrappers to pass callback functions with parameters to buttons below + callback_with_period = partial(self.special_character_handler, ".") + callback_with_exclamation = partial(self.special_character_handler, "!") + callback_with_comma = partial(self.special_character_handler, ",") + callback_with_question_mark = partial(self.special_character_handler, "?") + + ui.button(".", on_click=callback_with_period, color=COLOR_STYLE.secondary).classes("text-white") + ui.button("!", on_click=callback_with_exclamation, color=COLOR_STYLE.secondary).classes("text-white") + ui.button(",", on_click=callback_with_comma, color=COLOR_STYLE.secondary).classes("text-white") + ui.button("?", on_click=callback_with_question_mark, color=COLOR_STYLE.secondary).classes("text-white") + + @ui.page("/color_input") + def color_input_page(self) -> None: + """Create page displaying color_picker, character buttons, and text. + + This method allows the class to create a page separately from the WPM tester + and was used for testing the class. + """ + with ui.header(): + ui.label("Title text here?") + + with ui.left_drawer(): + ui.label("Special Keys:").style("font-weight:bold") + ui.separator().style("opacity:0") + ui.switch("CAPS LOCK", on_change=self.shift_handler) + ui.button("Confirm Letter", on_click=self.confirm_letter_handler) + ui.separator().props("color = black") + + # creating wrappers to pass callback functions with parameters to buttons below + callback_with_period = partial(self.special_character_handler, ".") + callback_with_exclamation = partial(self.special_character_handler, "!") + callback_with_comma = partial(self.special_character_handler, ",") + callback_with_question_mark = partial(self.special_character_handler, "?") + + ui.label("Special Characters:").style("font-weight:bold") + ui.separator().style("opacity:0") + with ui.grid(columns=2): + ui.button(".", on_click=callback_with_period) + ui.button("!", on_click=callback_with_exclamation) + ui.button(",", on_click=callback_with_comma) + ui.button("?", on_click=callback_with_question_mark) + + with ui.right_drawer(): + ui.label("Something could go here also") + + # ui labels displaying selected color, last input character, and text typed by user + self.color_label.text = f"Color Selected: {self.selected_color}" + self.input_label.text = f"Character Selected: {self.typed_char}" + + with ui.row(), ui.button(icon="colorize").style("opacity:0;pointer-events:none"): + ui.color_picker(on_pick=self.color_handler, value=True).props("persistent") + + ui.run() + + def find_closest_member(self, color_hex: str) -> str: + """Compare color hexcode to each color in class dicitionary. Return closest color. + + Takes a color hexcode and compares it to all colors in the class dictionary, finding the "closest" color + in the dictionary. Uses the Euclidean distance metric on the RGB values of the hexcode to compute distance. + The function returns the key of the most similar dict entry, which is the name of a color name (string). + + :param color_hex: a color hexcode + :return: name (string) of the color with the closest hexcode + """ + color_dists = [ + (key, ColorInputComponent.color_dist(color_hex, self.color_dict[key]), 2) for key in self.color_dict + ] + color_dists = sorted(color_dists, key=lambda e: e[1]) + + return color_dists[0][0] + + # Static methods below + + @staticmethod + def hex_to_rgb(color_hex: str) -> dict[str, int]: + """Return dictionary of RGB color values from color hexcode. + + Takes a color hexcode and returns a dictionary with (color,intensity) + key/value pairs for Red, Green, and Blue + + :param color_hex: string representing a color hexcode + :return: dictionary of color:intensity pairs + """ + hex_code_length = 6 + invalid_code = "Invalid color code" + if color_hex[0] == "#": + color_hex = color_hex[1:] + if len(color_hex) != hex_code_length: + raise ValueError(invalid_code) + + red_val = int(color_hex[0:2], 16) + green_val = int(color_hex[2:4], 16) + blue_val = int(color_hex[4:6], 16) + return {"red": red_val, "green": green_val, "blue": blue_val} + + @staticmethod + def color_dist(color_code1: str, color_code2: str) -> float: + """Return distance between two colors using their RGB values. + + Takes two hex_color codes and returns the "distance" between the colors. The distance is computed using the + Euclidean distance metric by treating the colors 3-tuples (RGB). Rounds to two decimal places. + + :param color_code1: string representing a color hexcode + :param color_code2: string representing a color hexcode + :return: float representing Euclidean distance between colors + """ + color_tuple_1 = ColorInputComponent.hex_to_rgb(color_code1) + color_tuple_2 = ColorInputComponent.hex_to_rgb(color_code2) + + red_delta = color_tuple_1["red"] - color_tuple_2["red"] + green_delta = color_tuple_1["green"] - color_tuple_2["green"] + blue_delta = color_tuple_1["blue"] - color_tuple_2["blue"] + + return round((red_delta**2 + green_delta**2 + blue_delta**2) ** 0.5, 2) diff --git a/monumental-monsteras/src/color_style.py b/monumental-monsteras/src/color_style.py new file mode 100644 index 00000000..a8815bb3 --- /dev/null +++ b/monumental-monsteras/src/color_style.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + + +@dataclass +class ColorStyle: + """The color theme for the website. + + Attributes: + primary (str): Primary color. + secondary (str): Secondary color. + primary_bg (str): Primary background color. + secondary_bg (str): Secondary background color. + contrast (str): Color that contrasts with the background color. + + """ + + def __init__(self) -> None: + self.primary: str = "#12E7B2" + self.secondary: str = "#7D53DE" + self.primary_bg: str = "#111111" + self.secondary_bg: str = "#1B1B1B" + self.contrast: str = "#E9E9E9" diff --git a/monumental-monsteras/src/config.py b/monumental-monsteras/src/config.py new file mode 100644 index 00000000..d6ebbd6e --- /dev/null +++ b/monumental-monsteras/src/config.py @@ -0,0 +1,48 @@ +from typing import TypedDict + +from audio_style_input import AudioEditorComponent +from color_mixer_input import ColorInputComponent +from input_method_proto import IInputMethod +from platformer_input import PlatformerInputMethod +from rpg_text_input import Keyboard + +PROJECT_NAME: str = "Dynamic Typing" +PROJECT_DESCRIPTION: str = "How fast can you type?" + + +class InputMethodSpec(TypedDict): + """Specifications for an input method to be added to the main page.""" + + name: str + path: str + icon: str + component: type[IInputMethod] | None + + +# INPUT METHODS +INPUT_METHODS: list[InputMethodSpec] = [ + { + "name": "Record Player", + "path": "audio-input", + "icon": "", + "component": AudioEditorComponent, + }, + { + "name": "WASD", + "path": "wasd", + "icon": "", + "component": Keyboard, + }, + { + "name": "Color Picker", + "path": "color-picker", + "icon": "", + "component": ColorInputComponent, + }, + { + "name": "Platformer", + "path": "platformer", + "icon": "", + "component": PlatformerInputMethod, + }, +] diff --git a/monumental-monsteras/src/homepage.py b/monumental-monsteras/src/homepage.py new file mode 100644 index 00000000..f23f9c85 --- /dev/null +++ b/monumental-monsteras/src/homepage.py @@ -0,0 +1,88 @@ +from pathlib import Path + +from nicegui import app, ui + +from color_style import ColorStyle +from config import INPUT_METHODS, PROJECT_DESCRIPTION, PROJECT_NAME + +COLOR_STYLE = ColorStyle() + +media = Path("./static") +app.add_media_files("/media", media) + + +def home() -> None: + """Render the home page.""" + ui.add_css(""" + .thick-header { + height: 350px; + justify-content: center; + } + .site-title { + font-family: Arial; + font-weight: bold; + text-align: center; + font-size: 70px; + } + .site-subtitle { + font-family: Arial; + text-align: center; + font-size: 20px; + } + .heading { + font-family: Arial; + font-size: 25px; + font-weight: bold; + text-align: center; + padding: 30px; + } + .input-box { + height: 300px; + width: 300px; + padding: 20px; + } + .input-grid { + justify-content: center; + } + .page-div { + position: absolute; + width: 90vw; + left: 50%; + transform: translate(-50%); + } + .button-parent { + display: flex; + gap: 1rem; + flex-wrap: wrap; + justify-content: center; + padding-top: 30px; + padding-bottom: 60px; + } + """) + + ui.query("body").style(f"background-color: {COLOR_STYLE.primary_bg}") + + with ( + ui.header(fixed=False) + .style(f"background-color: {COLOR_STYLE.secondary_bg}") + .classes("items-center thick-header"), + ui.column(align_items="center").style("gap: 0px;"), + ): + ui.label(PROJECT_NAME).style(f"color: {COLOR_STYLE.primary}").classes("site-title") + ui.label(PROJECT_DESCRIPTION).style(f"color: {COLOR_STYLE.contrast}").classes("site-subtitle") + + with ui.element("div").classes("page-div"): + ui.label("CHOOSE YOUR INPUT METHOD").style(f"color: {COLOR_STYLE.secondary}").classes("heading") + ui.separator().style("background-color: #313131;") + with ui.element("div").classes("button-parent"): + for input in INPUT_METHODS: + ( + ui.button( + text=input["name"], + color=COLOR_STYLE.secondary_bg, + on_click=lambda _, path=f"/test/{input['path']}": ui.navigate.to(path), + ) + .style(f"color: {COLOR_STYLE.contrast}") + .props("rounded") + .classes(f"input-box hover:!bg-[{COLOR_STYLE.primary}] transition-colors duration-300") + ) diff --git a/monumental-monsteras/src/input_method_proto.py b/monumental-monsteras/src/input_method_proto.py new file mode 100644 index 00000000..2ab6fcbc --- /dev/null +++ b/monumental-monsteras/src/input_method_proto.py @@ -0,0 +1,12 @@ +from collections.abc import Callable +from typing import Protocol + +type TextUpdateCallback = Callable[[str], None] + + +class IInputMethod(Protocol): + """An interface for any input method renderable in the WPM test page.""" + + def on_text_update(self, callback: TextUpdateCallback) -> None: + """Call `callback` every time the user input changes.""" + raise NotImplementedError diff --git a/monumental-monsteras/src/input_view.py b/monumental-monsteras/src/input_view.py new file mode 100644 index 00000000..94594132 --- /dev/null +++ b/monumental-monsteras/src/input_view.py @@ -0,0 +1,161 @@ +from collections.abc import Iterator + +from nicegui import ui + + +class input_view(ui.element): # noqa: N801 this is the nicegui convention + """The component which will display the user's input. + + Usage: + First, in the page handler, create the view -- do NOT put this in a refreshable + method, as the constructor adds CSS and will duplicate stuff. The `full_text` argument is + the **original** text, the thing that the user needs to type -- the user's input starts empty. + + Then, whenever your text updates, call `set_text` on the input view. This updates the + **user input** of the input view. If you need to change the background text for whatever + reason, use `set_original_text`. + + Example: + ```python + @ui.page("/input") + def page(): + state = State() + iv = input_view.input_view("The quick brown fox jumps over the lazy dog.") + def on_key(key): + state.text += key + iv.set_text(state.text) + input_method.on_key(on_key) + ``` + + """ + + CSS = """ +.input-view { + padding: 8px; + border-radius: 5px; + background-color: var(--q-dark); + font-family: monospace; + color: var(--q-secondary); + word-break: break-all; + display: grid; +} + +.input-view-bg { + font-size: 20pt; + grid-area: 1 / 1 / 2 / 2; +} + +.input-view-fg { + font-size: 20pt; + grid-area: 1 / 1 / 2 / 2; +} + +.input-view-fg div { + display: inline; +} + +.input-view-fg span.c, .input-view-fg div.c { + background-color: var(--q-positive); + color: var(--q-dark); +} + +.input-view-fg span.w, .input-view-fg div.w { + background-color: var(--q-negative); +} + +.input-view-fg span.cursor, .input-view-fg div.cursor { + color: white; + display: inline-block; +} +""" + + def __init__(self, full_text: str) -> None: + super().__init__("div") + ui.add_css(self.CSS) + + self.classes("input-view") + with self: + self.full_text_label = ui.label(full_text).classes("input-view-bg") + self.text_input = ui.element().classes("input-view-fg") + with self.text_input: + ui.label("_").classes("cursor") + + self.full_text = full_text + self.value = "" + + self.minutes, self.seconds = 0, 0 + + def update_timer(self) -> str: + """Update timer to get min and secs.""" + self.seconds += 1 + seconds_60 = 60 + if self.seconds == seconds_60: + self.seconds = 0 + self.minutes += 1 + return f"TIMER: {self.minutes:02d}:{self.seconds:02d}" + + def _parse_text(self, user_text: str) -> Iterator[tuple[str, bool]]: + """Get a token list of string slices and whether they are correct.""" + if len(user_text) == 0: + return iter(()) + + mask = [] + for i in range(len(user_text)): + if i < len(self.full_text): + mask.append(user_text[i] == self.full_text[i]) + else: + mask.append(False) + + index = 0 + cur_v = mask[0] + + while index < len(mask): + next_change_at = index + 1 + while next_change_at < len(mask) and mask[next_change_at] == cur_v: + next_change_at += 1 + yield (user_text[index:next_change_at], cur_v) + index = next_change_at + cur_v = not cur_v + + def set_original_text(self, value: str) -> None: + """Reset the **background** text. You're probably looking for `set_text`. + + Example: + ```python + # `text` is what the user is supposed to type. + def new_txt_selected_handler(text): + iv.set_original_text(text) + ... + ``` + + """ + self.full_text = value + self.full_text_label.set_text(value) + + def set_text(self, value: str) -> None: + """Set the current **user** input -- what should be displayed in the foreground. + + Additionally, it adds highlighting based on where the user types a correct character and + where the user types an incorrect character. + + Example: + ```python + state = State() + def on_key(key): + state.text += key + iv.set_text(state.text) # here + ... + input_method.on_key(on_key) + ``` + + """ + self.text_input.clear() + if value == "": + return + parsed = self._parse_text(value) + with self.text_input: + for tok, correct in parsed: + ui.html(tok.replace(" ", " ")).classes("c" if correct else "w") + + if len(value) < len(self.full_text): + ui.label("_").classes("cursor") diff --git a/monumental-monsteras/src/main.py b/monumental-monsteras/src/main.py new file mode 100644 index 00000000..724195ea --- /dev/null +++ b/monumental-monsteras/src/main.py @@ -0,0 +1,9 @@ +from nicegui import ui + +from homepage import home +from wpm_tester import wpm_tester_page + +ui.page("/")(home) +ui.page("/test/{method}")(wpm_tester_page) + +ui.run(title="Dynamic Typing", favicon="./static/images/favicon.ico") diff --git a/monumental-monsteras/src/platformer_input/__init__.py b/monumental-monsteras/src/platformer_input/__init__.py new file mode 100644 index 00000000..11dbf72b --- /dev/null +++ b/monumental-monsteras/src/platformer_input/__init__.py @@ -0,0 +1,77 @@ +import typing + +import nicegui.events +from nicegui import ui + +import input_method_proto +from platformer_input.platformer_scene_cmp import PlatformerRendererComponent +from platformer_input.platformer_simulation import PlatformerPhysicsSimulation + +INITIAL_POS = (1, 10) +FPS = 60 + + +class PlatformerInputMethod(input_method_proto.IInputMethod): + """The platformer input method. + + Users will control a 2D platformer player to move around and bump into blocks + to enter characters. + """ + + callbacks: list[typing.Callable[[str], None]] + renderer: PlatformerRendererComponent + held_keys: set[str] + input_value: str + capitalized: bool + + def __init__(self) -> None: + self.callbacks = [] + self.input_value = "" + self.renderer = PlatformerRendererComponent(INITIAL_POS) + self.simulation = PlatformerPhysicsSimulation(INITIAL_POS) + self.simulation.on_letter(self._on_simulation_letter) + self.held_keys = set() + self.capitalized = False + ui.keyboard(lambda e: self.keyboard_handler(e)) + ui.timer(1 / FPS, lambda: self._hinterv()) + + def keyboard_handler(self, event: nicegui.events.KeyEventArguments) -> None: + """Call with the nicegui keyboard callback.""" + evk = event.key.code + if event.action.repeat: + return + + if event.action.keydown: + self.held_keys.add(evk) + elif event.action.keyup and evk in self.held_keys: + self.held_keys.remove(evk) + self.simulation.set_held_keys(self.held_keys) + + if event.key.arrow_up and event.action.keydown: + self.capitalized = not self.capitalized + + def _on_simulation_letter(self, letter: str) -> None: + """Call when the simulation registers a letter press.""" + self.renderer.play_bounce_effect(letter) + if letter == "<": + if len(self.input_value) > 0: + self.input_value = self.input_value[:-1] + else: + if self.capitalized: + letter = letter.capitalize() + self.input_value += letter.replace("_", " ") + self._run_callbacks() + + def _run_callbacks(self) -> None: + """Run all component text update callbacks.""" + for c in self.callbacks: + c(self.input_value) + + def _hinterv(self) -> None: + """Run every game tick.""" + self.simulation.tick() + self.renderer.rerender(self.simulation.player_x, self.simulation.player_y, self.capitalized) + + def on_text_update(self, callback: typing.Callable[[str], None]) -> None: + """Call `callback` every time the user input changes.""" + self.callbacks.append(callback) diff --git a/monumental-monsteras/src/platformer_input/platformer_constants.py b/monumental-monsteras/src/platformer_input/platformer_constants.py new file mode 100644 index 00000000..827aac14 --- /dev/null +++ b/monumental-monsteras/src/platformer_input/platformer_constants.py @@ -0,0 +1,55 @@ +"""Constants for the platformer input method rendering & simulator.""" + +"""Size in pixels of each "tile".""" +TILE_SIZE = 35 +"""Width in tiles of the whole scene.""" +SCENE_WIDTH = 32 +"""Height in tiles of the whole scene.""" +SCENE_HEIGHT = 12 + +JUMP_FORCE = 14 +"""Max player speed.""" +MOV_SPEED = 200 +"""How fast the player accelerates.""" +ACCEL_SPEED = 150 +"""Essentially friction simulation""" +VELOCITY_DECAY_RATE = 10 +"""Gravity force""" +GRAVITY_FORCE = 17 + +COLOR_PLAYER = "purple" + + +SCENE = """############################################# +# # +# # +# u v w x y z . ! _ < # +# # +# # +# ################### +################## # +# k l m n o p q r s t # +# # +# # +# ################ ############### # +# # +# a b c d e f g h i j # +# # +# # +############################################# +""" + + +def world_grid() -> list[list[str]]: + """Get a grid of one-character `str`s representing the scene as a grid.""" + lines = SCENE.splitlines() + max_length = max(len(ln) for ln in lines) + + grid: list[list[str]] = [] + for line in lines: + lst = list(line) + if len(line) < max_length: + lst.extend(" " for _ in range(max_length - len(line))) + grid.append(lst) + + return grid diff --git a/monumental-monsteras/src/platformer_input/platformer_scene_cmp.py b/monumental-monsteras/src/platformer_input/platformer_scene_cmp.py new file mode 100644 index 00000000..3149390d --- /dev/null +++ b/monumental-monsteras/src/platformer_input/platformer_scene_cmp.py @@ -0,0 +1,116 @@ +from nicegui import ui + +import platformer_input.platformer_constants as c +from color_style import ColorStyle + +COLOR_STYLE = ColorStyle() + +EMOJIS = {"sky": "\U0001f600", "ground": "\U0001f61e", "letter": "\U0001f636"} + + +class PlatformerRendererComponent(ui.element): + """Displays the characters and scene within the game.""" + + mask_element: ui.element + world: list[list[str]] + letter_el_map: dict[str, ui.label] + + def __init__(self, position: tuple[int, int]) -> None: + super().__init__("div") + ui.add_css(""".platformer-input-method-element .tile { + font-size: 30px; + margin-top: -7px; + margin-left: -7px; + border-radius: 5px; +} +.platformer-input-method-element.capitalization .tile div { + text-transform: uppercase; +} +.platformer-input-method-element .tile.fade { + opacity: 60%; +} +.platformer-input-method-element .tile div { + transform: translateY(-42px); + text-align: center; + color: white; + font-weight: bold; + font-size: 1.5rem; + background-color: #0002; + border-radius: 100%; +} +.platformer-input-method-element .tile-bounce { + animation: bounce 200ms ease; +} +@keyframes bounce { + 0% { margin-top: 0; margin-bottom: 0; } + 50% { margin-top: -5px; margin-bottom: 5px; color: cyan; scale: 1.05 } + 100% { margin-top: 0; margin-bottom: 0; } +} +""") + self.classes("platformer-input-method-element flex items-center justify-center") + with self: + self.mask_element = ui.element("div").classes( + f"rounded-3xl m-4 border-4 border-[{COLOR_STYLE.primary}] border-double " + ) + self.mask_element.style( + f"width: {c.TILE_SIZE * c.SCENE_WIDTH}px; height: {c.TILE_SIZE * c.SCENE_HEIGHT}px;" + f"background-color: black; position: relative; overflow: hidden" + ) + + self.world = c.world_grid() + self.world_height = len(self.world) + self._initial_draw() + self.rerender(*position, True) + + def _initial_draw(self) -> None: + """Draw the map for the first time.""" + with self.mask_element: + self.map_container = ui.element("div") + self.map_container.style( + f"position:absolute;width:{len(self.world[0]) * c.TILE_SIZE}px;" + f"height:{len(self.world) * c.TILE_SIZE}px;" + f"display:grid;grid-template-columns:repeat({len(self.world[0])}, {c.TILE_SIZE}px);" + f"grid-template-rows:repeat({len(self.world)}, {c.TILE_SIZE}px);" + "left: 0; top: 0;" + ) + + self.px_player_offset_lx = ((c.SCENE_WIDTH - 1) * c.TILE_SIZE) / 2 + self.px_player_offset_ty = (c.SCENE_HEIGHT * c.TILE_SIZE) - 2 * c.TILE_SIZE + with self.mask_element: + ui.label("\U0001f7e6").style( + f"position:absolute;top:{self.px_player_offset_ty}px;left:{self.px_player_offset_lx}px;" + f"width:{c.TILE_SIZE}px;height:{c.TILE_SIZE}px;font-size:{c.TILE_SIZE}px;" + "transform: translate(-8px, -6px);" + ) + + self.letter_el_map = {} + + with self.map_container: + for row in self.world: + for cell in row: + if cell in "# ": + emoji = EMOJIS["ground"] if cell == "#" else EMOJIS["sky"] + classes = "tile" + if cell == " ": + classes += " fade" + ui.label(emoji).classes(classes) + else: + with ui.label(EMOJIS["letter"]).classes("tile"): + lb = ui.label(cell.replace("<", "\u232b")) + self.letter_el_map[cell] = lb + + def rerender(self, player_x: float, player_y: float, capitalization_state: bool) -> None: + """Move the player in the renderer.""" + px_left = self.px_player_offset_lx - player_x * c.TILE_SIZE + px_top = self.px_player_offset_ty - player_y * c.TILE_SIZE + + self.map_container.style(f"transform: translate({px_left}px, {px_top}px)") + self.classes("capitalization") if capitalization_state else self.classes(remove="capitalization") + + def play_bounce_effect(self, letter: str) -> None: + """Play a short bounce effect on a letter tile.""" + tile = self.letter_el_map.get(letter) + if tile is None: + return + tile.classes("tile-bounce") + ui.timer(0.2, lambda: tile.classes(remove="tile-bounce"), once=True) diff --git a/monumental-monsteras/src/platformer_input/platformer_simulation.py b/monumental-monsteras/src/platformer_input/platformer_simulation.py new file mode 100644 index 00000000..35e3a4c5 --- /dev/null +++ b/monumental-monsteras/src/platformer_input/platformer_simulation.py @@ -0,0 +1,143 @@ +import math +import time +import typing +from enum import Enum + +import platformer_input.platformer_constants as constants + +EPSILON = 1e-6 +type LetterHandler = typing.Callable[[str], None] + + +class TileType(Enum): + """A type of tile.""" + + AIR = 0 + BLOCK = 1 + LETTER = 2 + + def collide(self) -> bool: + """Whether collision is enabled on this tile.""" + return self.value > 0 + + +class PlatformerPhysicsSimulation: + """The physics simulation.""" + + player_x: float + player_y: float + + _xvel: float + _yvel: float + + _deltatime: float + _last_tick_at: float + + _keys: set[str] + _world: list[list[str]] + _letter_handlers: list[LetterHandler] + + def __init__(self, initial: tuple[int, int]) -> None: + self.player_x, self.player_y = initial + self._xvel = 0 + self._yvel = 0 + + self._deltatime = 0 + self._last_tick_at = 0 + + self._keys = set() + self._world = constants.world_grid() + self._letter_handlers = [] + + def set_held_keys(self, keys: set[str]) -> None: + """Set the current player-held keys.""" + self._keys = keys + + def tick(self) -> None: + """Run a tick of the simulation.""" + current_time = time.perf_counter() + if self._last_tick_at == 0: + self._last_tick_at = current_time + self._deltatime = current_time - self._last_tick_at + self._last_tick_at = current_time + + delta_accel = self._deltatime * constants.ACCEL_SPEED + + if "ArrowRight" in self._keys or "KeyD" in self._keys: + self._xvel = min(constants.MOV_SPEED, self._xvel + delta_accel) + if "ArrowLeft" in self._keys or "KeyA" in self._keys: + self._xvel = max(-constants.MOV_SPEED, self._xvel - delta_accel) + if ("ArrowUp" in self._keys or "KeyW" in self._keys or "Space" in self._keys) and self._collides( + (self.player_x, self.player_y + 2 * EPSILON) + ): + self._yvel = -constants.JUMP_FORCE + + self._apply_x_velocity() + self._apply_y_velocity() + + def on_letter(self, handler: LetterHandler) -> None: + """Register callback function for a letter being bumped.""" + self._letter_handlers.append(handler) + + def _apply_x_velocity(self) -> None: + """Apply horizontal velocity and decay.""" + decay_factor = 1 - constants.VELOCITY_DECAY_RATE * self._deltatime + decay_factor = max(decay_factor, 0) + self._xvel *= decay_factor + dx = self._xvel * self._deltatime + if dx != 0: + new_x = self.player_x + dx + if self._collides((new_x, self.player_y)): + self._xvel = 0 + if dx > 0: + player_edge_r = self.player_x + 1 + tile_edge = int(player_edge_r) + new_x = tile_edge - EPSILON + else: + tile_edge = int(self.player_x) + new_x = tile_edge + EPSILON + self.player_x = new_x + + def _apply_y_velocity(self) -> None: + """Apply gravity and vertical player clamping.""" + self._yvel += constants.GRAVITY_FORCE * self._deltatime + dy = self._yvel * self._deltatime + if dy != 0: + new_y = self.player_y + dy + if collision_result := self._collision_tile((self.player_x, new_y)): + self._yvel = 0 + if dy > 0: + player_edge_b = self.player_y + 1 + tile_edge = int(player_edge_b) + new_y = tile_edge - EPSILON + else: + tile_edge = int(self.player_y) + new_y = tile_edge + EPSILON + if collision_result != "#": + [x(collision_result) for x in self._letter_handlers] + self.player_y = new_y + + def _collides(self, player: tuple[float, float]) -> bool: + """Check if a position collides with a tile.""" + return bool(self._collision_tile(player)) + + def _collision_tile(self, player: tuple[float, float]) -> str | typing.Literal[False]: + """Check if the position collides with a tile, if so get the tile data.""" + player_left = player[0] + player_right = player[0] + 1 + player_top = player[1] + player_bottom = player[1] + 1 + + left_tile = math.floor(player_left) + right_tile = math.floor(player_right - EPSILON) + top_tile = math.floor(player_top) + bottom_tile = math.floor(player_bottom - EPSILON) + + for tile_y in range(top_tile, bottom_tile + 1): + for tile_x in range(left_tile, right_tile + 1): + in_map = 0 <= tile_y < len(self._world) and 0 <= tile_x < len(self._world[0]) + if not in_map: + continue + if (v := self._world[tile_y][tile_x]) != " ": + return v + return False diff --git a/monumental-monsteras/src/rpg_text_input/__init__.py b/monumental-monsteras/src/rpg_text_input/__init__.py new file mode 100644 index 00000000..69767d45 --- /dev/null +++ b/monumental-monsteras/src/rpg_text_input/__init__.py @@ -0,0 +1,134 @@ +from dataclasses import dataclass +from typing import override + +from nicegui import ui +from nicegui.events import KeyEventArguments + +from color_style import ColorStyle +from input_method_proto import IInputMethod, TextUpdateCallback + +COLOR_STYLE = ColorStyle() + + +def wrap_to_range(num: int, num_min: int, num_max: int) -> int: + """Ensure num is in the half-open interval [min, max), wrapping as needed. + + Returns: + The input num wrapped to the given range. + + Raises: + ValueError: If min is greater than or equal to max. + + """ + if num_min >= num_max: + msg = f"Wrapping doesn't make sense if min >= max, got {num_min=} and {num_max=}." + raise ValueError(msg) + while num < num_min: + num += num_max + while num >= num_max: + num -= num_max + return num + + +@dataclass +class WrappingPosition: + """X and Y position with wrapping addition.""" + + x: int + y: int + max_x: int + max_y: int + + def wrapping_add(self, x: int, y: int) -> "WrappingPosition": + """Add an X and a Y to self, wrapping as needed.""" + new_x = wrap_to_range(self.x + x, 0, self.max_x) + new_y = wrap_to_range(self.y + y, 0, self.max_y) + return WrappingPosition(new_x, new_y, self.max_x, self.max_y) + + +KEYBOARD_KEYS: tuple[str, ...] = ( + "ABCDEFGabcdefg", + "HIJKLMNhijklmn", + "OPQRSTUopqrstu", + "VWXYZ. vwxyz!\N{SYMBOL FOR BACKSPACE}", +) + + +class Keyboard(IInputMethod): + r"""A RPG-style keyboard where characters are selected by navigating with wasd/the arror keys. + + Positions are stored internally as (col, row). + """ + + def __init__(self) -> None: + self.position: WrappingPosition = WrappingPosition(0, 0, len(KEYBOARD_KEYS[0]), len(KEYBOARD_KEYS)) + self.callbacks: list[TextUpdateCallback] = [] + self.text: str = "" + + self.render() + ui.keyboard(on_key=self.handle_key) + + @ui.refreshable_method + def render(self) -> None: + """Render the keyboard to the page.""" + with ( + ui.element("div").classes("w-full h-full flex justify-center items-center"), # centering div + ui.element("div").classes( + f"w-[85%] h-[60%] bg-[{COLOR_STYLE.primary_bg}] p-5 rounded-xl" + ), # keyboard outer + ui.grid(columns=len(KEYBOARD_KEYS[0])).classes("h-full w-full"), # key grid + ): + for row_index, row in enumerate(KEYBOARD_KEYS): + for col_index, char in enumerate(row): + with ui.element("div").classes( # keys + f"w-full h-full flex justify-center items-center border-2 " + f"{ + 'bg-[' + COLOR_STYLE.primary + ']' + if (col_index, row_index) == (self.position.x, self.position.y) + else 'bg-[' + COLOR_STYLE.secondary_bg + ']' + } " + f"border-[{COLOR_STYLE.secondary_bg}] rounded-md" + ): + ( + ui.label(char) + .style("font-size: clamp(1rem, 3vh, 3rem)") + .classes(f"text-center text-[{COLOR_STYLE.contrast}] p-2") + ) + + def move(self, x: int, y: int) -> None: + """Move the keyboard selected character in the given directions.""" + self.position = self.position.wrapping_add(x, y) + self.render.refresh() + + def send_selected(self) -> None: + """Send the selected character to the input view.""" + key = KEYBOARD_KEYS[self.position.y][self.position.x] + if key != "\N{SYMBOL FOR BACKSPACE}": + self.text += key + else: + self.text = self.text[:-1] + + for callback in self.callbacks: + callback(self.text) + + def handle_key(self, e: KeyEventArguments) -> None: + """Input handler for the RPG style keyboard.""" + if not e.action.keydown: + return + + # Done using a for loop to minimize copy/paste errors + for key_codes, direction in ( + ({"KeyW", "ArrowUp"}, (0, -1)), + ({"KeyS", "ArrowDown"}, (0, 1)), + ({"KeyA", "ArrowLeft"}, (-1, 0)), + ({"KeyD", "ArrowRight"}, (1, 0)), + ): + if e.key.code in key_codes: + self.move(*direction) + + if e.key.code in {"Space", "Enter"}: + self.send_selected() + + @override + def on_text_update(self, callback: TextUpdateCallback) -> None: + self.callbacks.append(callback) diff --git a/monumental-monsteras/src/sample_input_method/__init__.py b/monumental-monsteras/src/sample_input_method/__init__.py new file mode 100644 index 00000000..40645e66 --- /dev/null +++ b/monumental-monsteras/src/sample_input_method/__init__.py @@ -0,0 +1,24 @@ +from typing import override + +from nicegui import ui + +from input_method_proto import IInputMethod, TextUpdateCallback + + +class SampleInputMethod(IInputMethod): + """A sample input method for basic reference. + + Consider using a dataclass instead with any complex state. + """ + + callbacks: list[TextUpdateCallback] + + def __init__(self) -> None: + self.callbacks = [] + self.inp = ui.input("input here") + self.inp.on_value_change(lambda event: [x(event.value) for x in self.callbacks]) + + @override + def on_text_update(self, callback: TextUpdateCallback) -> None: + """Call `callback` every time the user input changes.""" + self.callbacks.append(callback) diff --git a/monumental-monsteras/src/wpm_tester.py b/monumental-monsteras/src/wpm_tester.py new file mode 100644 index 00000000..8a7333e0 --- /dev/null +++ b/monumental-monsteras/src/wpm_tester.py @@ -0,0 +1,186 @@ +import secrets +import time +from dataclasses import dataclass +from pathlib import Path + +from faker import Faker +from nicegui import app, ui + +import input_method_proto +import input_view +from color_style import ColorStyle +from config import INPUT_METHODS, PROJECT_NAME + +COLOR_STYLE = ColorStyle() + +media = Path("./static") +app.add_media_files("/media", media) + +fake = Faker() + + +def get_input_method_by_name(inmth: str) -> type[input_method_proto.IInputMethod] | None: + """Get an input method class by its name.""" + for input_method in INPUT_METHODS: + if inmth == input_method["path"]: + return input_method["component"] + return None + + +@dataclass +class TimerState: + """Timer state class.""" + + active: bool = False + container: ui.timer | None = None + start: float | None = None + + +@dataclass +class WpmTesterPageState: + """The page state.""" + + """Useless for now, may be useful later?""" + text: str + + +def create_header() -> None: + """Create header and sidebar.""" + # Header + with ( + ui.header(wrap=False) + .style(f"background-color: {COLOR_STYLE.secondary_bg}") + .classes("flex items-center justify-between h-[8vh] py-0 px-4") + ): + with ui.link(target="/").classes("w-[30px] h-auto"): + ui.image("/media/images/logo-icon.png") + ( + ui.link(PROJECT_NAME.upper(), "/") + .style(f"color: {COLOR_STYLE.primary}; font-family: Arial, sans-serif; text-decoration: none") + .classes("text-4xl font-bold") + ) + ui.button(on_click=lambda: right_drawer.toggle(), icon="menu").props("flat color=white") + + # Sidebar + with ( + ui.right_drawer(value=False, fixed=False) + .style(f"background-color: {COLOR_STYLE.secondary_bg}") + .props("overlay") + .classes("p-0") as right_drawer, + ui.element("q-scroll-area").classes("fit"), + ): + # Home nav button + with ( + ui.list().classes("fit"), + ui.item(on_click=lambda: ui.navigate.to("/")) + .props("clickable") + .classes(f"hover:bg-[{COLOR_STYLE.primary}]"), + ui.item_section(), + ): + ui.label("HOME").style(f"color: {COLOR_STYLE.contrast}") + + with ui.list().classes("fit"), ui.column().classes("w-full items-center"): + ui.separator().style("background-color: #313131; width: 95%;") + + # Input method nav buttons + with ui.list().classes("fit"): + for input_method in INPUT_METHODS: + path = f"/test/{input_method['path']}" + with ( + ui.item(on_click=lambda _, p=path: ui.navigate.to(p)) + .props("clickable") + .classes(f"hover:bg-[{COLOR_STYLE.primary}]"), + ui.item_section(), + ): + ui.label(input_method["name"].upper()).style(f"color: {COLOR_STYLE.contrast}") + + +def create_time_chips() -> tuple[ui.chip, ui.chip, ui.chip]: + """Create chips for timer, wpm, and wph.""" + with ui.row().classes("w-full justify-center items-center gap-4"): + timer_label = ui.chip("TIMER: 0:00", color="#6AC251", icon="timer") + wpm_label = ui.chip("WPM: --", color="#e5e5e5", icon="watch") + wph_label = ui.chip("WPH: --", color="#e5e5e5", icon="hourglass_top") + + return timer_label, wpm_label, wph_label + + +def setup( + method: str, + text_to_use: str, + state: WpmTesterPageState, + chip_package: tuple[ui.chip, ui.chip, ui.chip], + iv: input_view.input_view, +) -> None: + """Set up input method updates and timer handling.""" + input_method_def = get_input_method_by_name(method) + if input_method_def is None: + return + + input_method = input_method_def() + timer = TimerState() + timer_label, wpm_label, wph_label = chip_package + + def stop_timer() -> None: + if timer.container: + timer.container.deactivate() + + def on_text_update(txt: str) -> None: + if not timer.active: + timer.container = ui.timer(1, lambda: timer_label.set_text(iv.update_timer())) + timer.active = True + timer.start = time.time() + + iv.set_text(txt) + state.text = txt + + if txt == text_to_use: + elapsed = time.time() - timer.start if timer.start else 0 + if elapsed > 0: + wpm = (len(txt) / 5) / (elapsed / 60) + wpm_label.set_text(f"Finished! WPM: {int(wpm)}") + wph_label.set_text(f"Finished! WPH: {int(wpm * 60)}") + stop_timer() + + input_method.on_text_update(on_text_update) + ui.on("disconnect", stop_timer) + + +def create_sentence() -> str: + """Create sentence to use in challenge.""" + punctuation = [".", "!"] + sentence = fake.sentence(nb_words=6, variable_nb_words=False) + return sentence[:-1] + secrets.choice(punctuation) + + +async def wpm_tester_page(method: str) -> None: + """Create the WPM tester page for a given input method.""" + input_method_def = get_input_method_by_name(method) + if input_method_def is None: + ui.navigate.to("/") + return + + state = WpmTesterPageState("") + text_to_use = create_sentence() + + create_header() + + # Main body + ui.query("body").style(f"background-color: {COLOR_STYLE.primary_bg};") + + with ( + ui.element("div") + .style(f"background-color: {COLOR_STYLE.secondary_bg}") + .classes( + """flex flex-col justify-evenly items-center absolute w-[90vw] h-[85vh] left-1/2 top-1/2 + transform -translate-x-1/2 -translate-y-1/2 rounded-xl""" + ) + ): + # Sentence and timer div + with ui.element("div").classes("flex items-center w-full h-1/4 p-5"): + iv = input_view.input_view(text_to_use).classes("w-full") + chip_package = create_time_chips() + + # Input method div + with ui.element("div").classes("align-items w-full h-3/4"): + setup(method, text_to_use, state, chip_package, iv) diff --git a/monumental-monsteras/static/images/favicon.ico b/monumental-monsteras/static/images/favicon.ico new file mode 100644 index 00000000..c0e4561d Binary files /dev/null and b/monumental-monsteras/static/images/favicon.ico differ diff --git a/monumental-monsteras/static/images/logo-icon.png b/monumental-monsteras/static/images/logo-icon.png new file mode 100644 index 00000000..461e5241 Binary files /dev/null and b/monumental-monsteras/static/images/logo-icon.png differ diff --git a/monumental-monsteras/static/images/record.png b/monumental-monsteras/static/images/record.png new file mode 100644 index 00000000..453a0852 Binary files /dev/null and b/monumental-monsteras/static/images/record.png differ diff --git a/monumental-monsteras/static/sounds/fast_forward.mp3 b/monumental-monsteras/static/sounds/fast_forward.mp3 new file mode 100644 index 00000000..49d66b37 Binary files /dev/null and b/monumental-monsteras/static/sounds/fast_forward.mp3 differ diff --git a/monumental-monsteras/static/sounds/rewind.mp3 b/monumental-monsteras/static/sounds/rewind.mp3 new file mode 100644 index 00000000..574597bc Binary files /dev/null and b/monumental-monsteras/static/sounds/rewind.mp3 differ